mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-26 23:51:14 +02:00
Merge remote-tracking branch 'upstream/dev' into fix/models
This commit is contained in:
commit
a2d5467023
1952 changed files with 103406 additions and 35663 deletions
|
|
@ -25,6 +25,7 @@ export function LanguageSwitcher() {
|
|||
{ code: "pt" as const, name: "Português", flag: "🇧🇷" },
|
||||
{ code: "hi" as const, name: "हिन्दी", flag: "🇮🇳" },
|
||||
{ code: "zh" as const, name: "简体中文", flag: "🇨🇳" },
|
||||
{ code: "ko" as const, name: "한국어", flag: "🇰🇷" },
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
@ -32,7 +33,7 @@ export function LanguageSwitcher() {
|
|||
* Updates locale in context and localStorage
|
||||
*/
|
||||
const handleLanguageChange = (newLocale: string) => {
|
||||
setLocale(newLocale as "en" | "es" | "pt" | "hi" | "zh");
|
||||
setLocale(newLocale as "en" | "es" | "pt" | "hi" | "zh" | "ko");
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -20,14 +20,10 @@ import { useAnnouncements } from "@/hooks/use-announcements";
|
|||
* Behaviour:
|
||||
* - On load, the first active, audience-matched, unread spotlight announcement
|
||||
* is shown automatically.
|
||||
* - The user must explicitly acknowledge it ("Got it" or the CTA link), which
|
||||
* marks it as read so it never shows again.
|
||||
* - Closing via the X / Escape / outside-click only hides it for the current
|
||||
* session; it reappears on the next load until the user marks it as seen.
|
||||
* - Any dismissal marks it as read so it never shows again.
|
||||
*/
|
||||
export function AnnouncementSpotlight() {
|
||||
const { announcements, markRead } = useAnnouncements();
|
||||
const [sessionDismissed, setSessionDismissed] = useState<Set<string>>(() => new Set());
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
// Short delay so the spotlight doesn't flash during initial hydration/layout.
|
||||
|
|
@ -37,11 +33,8 @@ export function AnnouncementSpotlight() {
|
|||
}, []);
|
||||
|
||||
const current = useMemo(
|
||||
() =>
|
||||
announcements.find(
|
||||
(a) => a.spotlight && a.isImportant && !a.isRead && !sessionDismissed.has(a.id)
|
||||
) ?? null,
|
||||
[announcements, sessionDismissed]
|
||||
() => announcements.find((a) => a.spotlight && a.isImportant && !a.isRead) ?? null,
|
||||
[announcements]
|
||||
);
|
||||
|
||||
if (!current) return null;
|
||||
|
|
@ -51,13 +44,7 @@ export function AnnouncementSpotlight() {
|
|||
};
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
if (!next) {
|
||||
setSessionDismissed((prev) => {
|
||||
const updated = new Set(prev);
|
||||
updated.add(current.id);
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
if (!next) markRead(current.id);
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -82,7 +69,7 @@ export function AnnouncementSpotlight() {
|
|||
</DialogDescription>
|
||||
<DialogFooter className="mt-2">
|
||||
{current.link && (
|
||||
<Button variant="outline" asChild className="gap-1.5" onClick={handleAcknowledge}>
|
||||
<Button variant="secondary" asChild className="gap-1.5" onClick={handleAcknowledge}>
|
||||
<Link
|
||||
href={current.link.url}
|
||||
target={current.link.url.startsWith("http") ? "_blank" : undefined}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { useAnnouncements } from "@/hooks/use-announcements";
|
|||
|
||||
export function AnnouncementsDialog() {
|
||||
const [open, setOpen] = useAtom(announcementsDialogAtom);
|
||||
const { announcements, markAllRead } = useAnnouncements();
|
||||
const { announcements, markAllRead } = useAnnouncements({ includeExpired: true });
|
||||
|
||||
// Auto-mark all visible announcements as read when the dialog opens
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -29,12 +29,13 @@ import {
|
|||
globalModelConnectionsAtom,
|
||||
modelConnectionsAtom,
|
||||
} from "@/atoms/model-connections/model-connections-query.atoms";
|
||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { activeWorkspaceIdAtom } from "@/atoms/workspaces/workspace-query.atoms";
|
||||
import {
|
||||
CitationMetadataProvider,
|
||||
useAllCitationMetadata,
|
||||
} from "@/components/assistant-ui/citation-metadata-context";
|
||||
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
|
||||
import { MessageTimestamp } from "@/components/assistant-ui/message-timestamp";
|
||||
import { ReasoningMessagePart } from "@/components/assistant-ui/reasoning-message-part";
|
||||
import { RevertTurnButton } from "@/components/assistant-ui/revert-turn-button";
|
||||
import {
|
||||
|
|
@ -62,6 +63,7 @@ import { withArtifactAnchor } from "@/features/chat-artifacts";
|
|||
import { useComments } from "@/hooks/use-comments";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { useElectronAPI } from "@/hooks/use-platform";
|
||||
import { formatMessageTimestamp } from "@/lib/format-date";
|
||||
import { getProviderIcon } from "@/lib/provider-icons";
|
||||
import { tryGetHostname } from "@/lib/url";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -249,16 +251,6 @@ export const MessageError: FC = () => {
|
|||
);
|
||||
};
|
||||
|
||||
function formatMessageDate(date: Date): string {
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format provider USD cost (in micro-USD) for inline display next to a
|
||||
* token count. Falls back to ``"<$0.001"`` for sub-tenth-of-a-cent
|
||||
|
|
@ -367,7 +359,7 @@ const MessageInfoDropdown: FC<{ chatTurnId: string | null | undefined }> = ({ ch
|
|||
>
|
||||
{createdAt && (
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground font-normal select-none">
|
||||
{formatMessageDate(createdAt)}
|
||||
{formatMessageTimestamp(createdAt)}
|
||||
</DropdownMenuLabel>
|
||||
)}
|
||||
{hasUsage && (
|
||||
|
|
@ -463,6 +455,8 @@ const AssistantMessageInner: FC = () => {
|
|||
<MessageError />
|
||||
</div>
|
||||
|
||||
<MessageTimestamp className="ml-2 mt-2" />
|
||||
|
||||
{isMobile && (
|
||||
<div className="ml-2 mt-2">
|
||||
<MobileCitationDrawer />
|
||||
|
|
@ -491,7 +485,7 @@ export const AssistantMessage: FC = () => {
|
|||
const commentPanelRef = useRef<HTMLDivElement>(null);
|
||||
const commentTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const messageId = useAuiState(({ message }) => message?.id);
|
||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||
const workspaceId = useAtomValue(activeWorkspaceIdAtom);
|
||||
const dbMessageId = parseMessageId(messageId);
|
||||
const commentsEnabled = useAtomValue(commentsEnabledAtom);
|
||||
|
||||
|
|
@ -520,7 +514,7 @@ export const AssistantMessage: FC = () => {
|
|||
const commentCount = commentsData?.total_count ?? 0;
|
||||
const hasComments = commentCount > 0;
|
||||
|
||||
const showCommentTrigger = searchSpaceId && commentsEnabled && !isMessageStreaming && dbMessageId;
|
||||
const showCommentTrigger = workspaceId && commentsEnabled && !isMessageStreaming && dbMessageId;
|
||||
|
||||
// Close floating panel when clicking outside (but not on portaled popover/dropdown content)
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -22,9 +22,19 @@ const ChatScrollToBottom: FC = () => (
|
|||
export interface ChatViewportProps {
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
/**
|
||||
* Keep the footer (composer) pinned even when the thread has no messages —
|
||||
* needed while an existing thread's messages are still loading, so the
|
||||
* bottom composer stays visible above the loading skeleton.
|
||||
*/
|
||||
footerAlwaysVisible?: boolean;
|
||||
}
|
||||
|
||||
export const ChatViewport: FC<ChatViewportProps> = ({ children, footer }) => (
|
||||
export const ChatViewport: FC<ChatViewportProps> = ({
|
||||
children,
|
||||
footer,
|
||||
footerAlwaysVisible = false,
|
||||
}) => (
|
||||
<ThreadPrimitive.Viewport
|
||||
turnAnchor="top"
|
||||
autoScroll
|
||||
|
|
@ -40,7 +50,7 @@ export const ChatViewport: FC<ChatViewportProps> = ({ children, footer }) => (
|
|||
/>
|
||||
{children}
|
||||
{footer ? (
|
||||
<AuiIf condition={({ thread }) => !thread.isEmpty}>
|
||||
<AuiIf condition={({ thread }) => footerAlwaysVisible || !thread.isEmpty}>
|
||||
<ThreadPrimitive.ViewportFooter
|
||||
className="aui-chat-composer-footer sticky bottom-0 z-20 -mx-4 mt-auto flex flex-col items-stretch bg-gradient-to-t from-main-panel from-60% to-transparent px-4 pt-6"
|
||||
style={{ paddingBottom: "max(0.5rem, env(safe-area-inset-bottom))" }}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useAuiState } from "@assistant-ui/react";
|
||||
import { createContext, type FC, type ReactNode, useContext, useMemo } from "react";
|
||||
import { createContext, type FC, type ReactNode, useContext } from "react";
|
||||
|
||||
export interface CitationMeta {
|
||||
title: string;
|
||||
|
|
@ -10,50 +9,16 @@ export interface CitationMeta {
|
|||
|
||||
type CitationMetadataMap = ReadonlyMap<string, CitationMeta>;
|
||||
|
||||
const CitationMetadataContext = createContext<CitationMetadataMap>(new Map());
|
||||
const EMPTY_CITATION_METADATA: CitationMetadataMap = new Map();
|
||||
|
||||
interface ToolCallResult {
|
||||
status?: string;
|
||||
citations?: Record<string, { title: string; snippet?: string }>;
|
||||
}
|
||||
|
||||
interface MessageContent {
|
||||
type: string;
|
||||
toolName?: string;
|
||||
result?: unknown;
|
||||
}
|
||||
const CitationMetadataContext = createContext<CitationMetadataMap>(EMPTY_CITATION_METADATA);
|
||||
|
||||
// The previous web-search citation spine (WEB_RESULT hover cards) was removed
|
||||
// with the multi-engine web_search tool. Agent citation logic is being reworked
|
||||
// wholesale, so this provider currently yields no web citation metadata.
|
||||
export const CitationMetadataProvider: FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const content = useAuiState(
|
||||
({ message }) => (message as { content?: MessageContent[] })?.content
|
||||
);
|
||||
|
||||
const metadataMap = useMemo<CitationMetadataMap>(() => {
|
||||
if (!content || !Array.isArray(content)) return new Map();
|
||||
|
||||
const merged = new Map<string, CitationMeta>();
|
||||
|
||||
for (const part of content) {
|
||||
if (part.type !== "tool-call" || part.toolName !== "web_search" || !part.result) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = part.result as ToolCallResult;
|
||||
const citations = result.citations;
|
||||
if (!citations || typeof citations !== "object") continue;
|
||||
|
||||
for (const [url, meta] of Object.entries(citations)) {
|
||||
if (url.startsWith("http") && meta.title && !merged.has(url)) {
|
||||
merged.set(url, { title: meta.title, snippet: meta.snippet });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}, [content]);
|
||||
|
||||
return (
|
||||
<CitationMetadataContext.Provider value={metadataMap}>
|
||||
<CitationMetadataContext.Provider value={EMPTY_CITATION_METADATA}>
|
||||
{children}
|
||||
</CitationMetadataContext.Provider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,349 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
LayoutGrid,
|
||||
Settings2,
|
||||
TriangleAlert,
|
||||
Unplug,
|
||||
Upload,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { type ReactNode, useCallback, useRef, useState } from "react";
|
||||
import type { ConnectorRow } from "@/components/assistant-ui/connector-popup/hooks/use-connector-rows";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerHandle,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/components/ui/drawer";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import {
|
||||
CONNECTOR_TOOL_ICON_PATHS,
|
||||
getToolDisplayName,
|
||||
getToolIcon,
|
||||
} from "@/contracts/enums/toolIcons";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Minimal shape of a grouped agent-tool section (mirrors thread.tsx). */
|
||||
export interface ToolGroupView {
|
||||
label: string;
|
||||
tools: { name: string }[];
|
||||
connectorIcon?: string;
|
||||
}
|
||||
|
||||
interface ComposerAddMenuDrawerProps {
|
||||
/** The `+` button; rendered as the drawer trigger. */
|
||||
trigger: ReactNode;
|
||||
onUploadFiles: () => void;
|
||||
/** Connected connectors: one row per type, with live indexing health (`useConnectorRows`). */
|
||||
connectorRows: ConnectorRow[];
|
||||
/** Open a connector's manage view (deep-links via importConnectorRequestAtom). */
|
||||
onSelectConnector: (row: ConnectorRow) => void;
|
||||
/** Navigate to the full connectors catalog. */
|
||||
onBrowseConnectors: () => void;
|
||||
regularToolGroups: ToolGroupView[];
|
||||
connectorToolGroups: ToolGroupView[];
|
||||
otherToolGroup?: ToolGroupView;
|
||||
disabledToolsSet: Set<string>;
|
||||
onToggleTool: (name: string) => void;
|
||||
onToggleToolGroup: (names: string[]) => void;
|
||||
/** True while the tool list is still loading (shows a skeleton). */
|
||||
toolsLoading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mobile "+" menu. A single vaul drawer that behaves like a flat list at the
|
||||
* root and drills into submenus in place (each level replaces the previous,
|
||||
* with a back button) rather than nesting overlays — the touch-friendly
|
||||
* equivalent of the desktop dropdown submenus. Screen state is a small push/pop
|
||||
* stack; closing the drawer resets it to the root.
|
||||
*/
|
||||
type Screen =
|
||||
| { kind: "root" }
|
||||
| { kind: "connectors" }
|
||||
| { kind: "tools" }
|
||||
| { kind: "toolGroup"; label: string };
|
||||
|
||||
const ROW = "flex w-full items-center gap-3 px-4 py-3 text-sm hover:bg-accent hover:text-accent-foreground transition-colors";
|
||||
|
||||
export function ComposerAddMenuDrawer({
|
||||
trigger,
|
||||
onUploadFiles,
|
||||
connectorRows,
|
||||
onSelectConnector,
|
||||
onBrowseConnectors,
|
||||
regularToolGroups,
|
||||
connectorToolGroups,
|
||||
otherToolGroup,
|
||||
disabledToolsSet,
|
||||
onToggleTool,
|
||||
onToggleToolGroup,
|
||||
toolsLoading,
|
||||
}: ComposerAddMenuDrawerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [stack, setStack] = useState<Screen[]>([{ kind: "root" }]);
|
||||
// Slide direction: forward on push, back on pop — drives the enter animation.
|
||||
const dirRef = useRef<"forward" | "back">("forward");
|
||||
const current = stack[stack.length - 1];
|
||||
|
||||
const push = useCallback((screen: Screen) => {
|
||||
dirRef.current = "forward";
|
||||
setStack((prev) => [...prev, screen]);
|
||||
}, []);
|
||||
const pop = useCallback(() => {
|
||||
dirRef.current = "back";
|
||||
setStack((prev) => (prev.length > 1 ? prev.slice(0, -1) : prev));
|
||||
}, []);
|
||||
|
||||
const handleOpenChange = useCallback((next: boolean) => {
|
||||
setOpen(next);
|
||||
// Reset to root when closed so the next open starts fresh.
|
||||
if (!next) {
|
||||
dirRef.current = "forward";
|
||||
setStack([{ kind: "root" }]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const close = useCallback(() => handleOpenChange(false), [handleOpenChange]);
|
||||
|
||||
const title =
|
||||
current.kind === "connectors"
|
||||
? "MCP Connectors"
|
||||
: current.kind === "tools"
|
||||
? "Manage Tools"
|
||||
: current.kind === "toolGroup"
|
||||
? current.label
|
||||
: "Add";
|
||||
|
||||
const renderToolRow = (name: string) => {
|
||||
const isDisabled = disabledToolsSet.has(name);
|
||||
const ToolIcon = getToolIcon(name);
|
||||
return (
|
||||
<div key={name} className={ROW}>
|
||||
<ToolIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate font-medium">{getToolDisplayName(name)}</span>
|
||||
<Switch
|
||||
checked={!isDisabled}
|
||||
onCheckedChange={() => onToggleTool(name)}
|
||||
className="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderBody = () => {
|
||||
if (current.kind === "root") {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={ROW}
|
||||
onClick={() => {
|
||||
onUploadFiles();
|
||||
close();
|
||||
}}
|
||||
>
|
||||
<Upload className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 text-left">Upload Files</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={ROW}
|
||||
onClick={() => push({ kind: "connectors" })}
|
||||
>
|
||||
<Unplug className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 text-left">MCP Connectors</span>
|
||||
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
<button type="button" className={ROW} onClick={() => push({ kind: "tools" })}>
|
||||
<Settings2 className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 text-left">Manage Tools</span>
|
||||
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (current.kind === "connectors") {
|
||||
return (
|
||||
<>
|
||||
{connectorRows.length === 0 ? (
|
||||
<p className="px-4 py-6 text-center text-sm text-muted-foreground">
|
||||
No connectors yet.
|
||||
</p>
|
||||
) : (
|
||||
connectorRows.map((row) => (
|
||||
<button
|
||||
type="button"
|
||||
key={row.type}
|
||||
className={ROW}
|
||||
onClick={() => {
|
||||
onSelectConnector(row);
|
||||
close();
|
||||
}}
|
||||
>
|
||||
{getConnectorIcon(row.type, "size-4 shrink-0 text-muted-foreground")}
|
||||
<span className="min-w-0 flex-1 truncate text-left">{row.title}</span>
|
||||
{row.health === "syncing" ? (
|
||||
<Spinner size="xs" className="shrink-0" />
|
||||
) : row.health === "failed" ? (
|
||||
<TriangleAlert
|
||||
className="size-4 shrink-0 text-destructive"
|
||||
aria-label={row.errorMessage ?? "Indexing failed"}
|
||||
/>
|
||||
) : row.accountCount > 1 ? (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{row.accountCount}</span>
|
||||
) : null}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
<Separator className="my-1" />
|
||||
<button
|
||||
type="button"
|
||||
className={ROW}
|
||||
onClick={() => {
|
||||
onBrowseConnectors();
|
||||
close();
|
||||
}}
|
||||
>
|
||||
<LayoutGrid className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 text-left">Manage connectors</span>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (current.kind === "toolGroup") {
|
||||
const group = connectorToolGroups.find((g) => g.label === current.label);
|
||||
return <>{group?.tools.map((t) => renderToolRow(t.name))}</>;
|
||||
}
|
||||
|
||||
// current.kind === "tools"
|
||||
if (toolsLoading) {
|
||||
return (
|
||||
<div className="px-4 pt-3 pb-2">
|
||||
<Skeleton className="h-3 w-16 mb-2" />
|
||||
{["t1", "t2", "t3", "t4"].map((k) => (
|
||||
<div key={k} className="flex items-center gap-3 py-2">
|
||||
<Skeleton className="size-4 rounded shrink-0" />
|
||||
<Skeleton className="h-3.5 flex-1" />
|
||||
<Skeleton className="h-5 w-9 rounded-full shrink-0" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{regularToolGroups.map((group) => (
|
||||
<div key={group.label}>
|
||||
<div className="px-4 pt-3 pb-1 text-xs text-muted-foreground font-semibold select-none">
|
||||
{group.label}
|
||||
</div>
|
||||
{group.tools.map((t) => renderToolRow(t.name))}
|
||||
</div>
|
||||
))}
|
||||
{connectorToolGroups.length > 0 && (
|
||||
<div>
|
||||
<div className="px-4 pt-3 pb-1 text-xs text-muted-foreground font-semibold select-none">
|
||||
Connector Actions
|
||||
</div>
|
||||
{connectorToolGroups.map((group) => {
|
||||
const iconInfo = CONNECTOR_TOOL_ICON_PATHS[group.connectorIcon ?? ""];
|
||||
const toolNames = group.tools.map((t) => t.name);
|
||||
const allDisabled = toolNames.every((n) => disabledToolsSet.has(n));
|
||||
return (
|
||||
<div key={group.label} className={ROW}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-3"
|
||||
onClick={() => push({ kind: "toolGroup", label: group.label })}
|
||||
>
|
||||
{iconInfo ? (
|
||||
<Image
|
||||
src={iconInfo.src}
|
||||
alt={iconInfo.alt}
|
||||
width={18}
|
||||
height={18}
|
||||
className="size-[18px] shrink-0 select-none pointer-events-none"
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<Wrench className="size-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate text-left font-medium">
|
||||
{group.label}
|
||||
</span>
|
||||
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
<Switch
|
||||
checked={!allDisabled}
|
||||
onCheckedChange={() => onToggleToolGroup(toolNames)}
|
||||
className="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{otherToolGroup && (
|
||||
<div>
|
||||
<div className="px-4 pt-3 pb-1 text-xs text-muted-foreground font-semibold select-none">
|
||||
{otherToolGroup.label}
|
||||
</div>
|
||||
{otherToolGroup.tools.map((t) => renderToolRow(t.name))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={handleOpenChange} shouldScaleBackground={false}>
|
||||
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
|
||||
<DrawerContent className="h-[85vh] max-h-[85vh] z-80" overlayClassName="z-80">
|
||||
<DrawerHandle />
|
||||
<DrawerHeader className="flex flex-row items-center gap-1 px-2 pb-2 pt-1">
|
||||
{stack.length > 1 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-9 shrink-0"
|
||||
onClick={pop}
|
||||
aria-label="Back"
|
||||
>
|
||||
<ChevronLeft className="size-5" />
|
||||
</Button>
|
||||
) : (
|
||||
<span className="size-9 shrink-0" aria-hidden />
|
||||
)}
|
||||
<DrawerTitle className="flex-1 text-center text-base font-semibold">{title}</DrawerTitle>
|
||||
<span className="size-9 shrink-0" aria-hidden />
|
||||
</DrawerHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-thin pb-6">
|
||||
<div
|
||||
key={current.kind === "toolGroup" ? `toolGroup:${current.label}` : current.kind}
|
||||
className={cn(
|
||||
"animate-in fade-in-0 duration-200",
|
||||
dirRef.current === "forward" ? "slide-in-from-right-4" : "slide-in-from-left-4"
|
||||
)}
|
||||
>
|
||||
{renderBody()}
|
||||
</div>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,388 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
|
||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import { useConnectorsSync } from "@/hooks/use-connectors-sync";
|
||||
import { PICKER_CLOSE_EVENT, PICKER_OPEN_EVENT } from "@/hooks/use-google-picker";
|
||||
import { useZeroDocumentTypeCounts } from "@/hooks/use-zero-document-type-counts";
|
||||
import { ConnectorDialogHeader } from "./connector-popup/components/connector-dialog-header";
|
||||
import { ConnectorConnectView } from "./connector-popup/connector-configs/views/connector-connect-view";
|
||||
import { ConnectorEditView } from "./connector-popup/connector-configs/views/connector-edit-view";
|
||||
import { IndexingConfigurationView } from "./connector-popup/connector-configs/views/indexing-configuration-view";
|
||||
import {
|
||||
COMPOSIO_CONNECTORS,
|
||||
OAUTH_CONNECTORS,
|
||||
} from "./connector-popup/constants/connector-constants";
|
||||
import { useConnectorDialog } from "./connector-popup/hooks/use-connector-dialog";
|
||||
import { useIndexingConnectors } from "./connector-popup/hooks/use-indexing-connectors";
|
||||
import { ActiveConnectorsTab } from "./connector-popup/tabs/active-connectors-tab";
|
||||
import { AllConnectorsTab } from "./connector-popup/tabs/all-connectors-tab";
|
||||
import { ConnectorAccountsListView } from "./connector-popup/views/connector-accounts-list-view";
|
||||
import { YouTubeCrawlerView } from "./connector-popup/views/youtube-crawler-view";
|
||||
|
||||
export interface ConnectorIndicatorHandle {
|
||||
open: () => void;
|
||||
}
|
||||
|
||||
interface ConnectorIndicatorProps {
|
||||
showTrigger?: boolean;
|
||||
}
|
||||
|
||||
export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, ConnectorIndicatorProps>(
|
||||
(_props, ref) => {
|
||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||
|
||||
// Real-time document type counts via Zero (updates instantly as docs are indexed)
|
||||
const documentTypeCounts = useZeroDocumentTypeCounts(searchSpaceId);
|
||||
// Read status inbox items from shared atom (populated by LayoutDataProvider)
|
||||
// instead of creating a duplicate useInbox("status") hook.
|
||||
const statusInboxItems = useAtomValue(statusInboxItemsAtom);
|
||||
const inboxItems = useMemo(
|
||||
() => statusInboxItems.filter((item) => item.type === "connector_indexing"),
|
||||
[statusInboxItems]
|
||||
);
|
||||
|
||||
// Use the custom hook for dialog state management
|
||||
const {
|
||||
isOpen,
|
||||
activeTab,
|
||||
connectingId,
|
||||
isScrolled,
|
||||
searchQuery,
|
||||
indexingConfig,
|
||||
indexingConnector,
|
||||
indexingConnectorConfig,
|
||||
editingConnector,
|
||||
connectingConnectorType,
|
||||
isCreatingConnector,
|
||||
startDate,
|
||||
endDate,
|
||||
isStartingIndexing,
|
||||
isSaving,
|
||||
isDisconnecting,
|
||||
periodicEnabled,
|
||||
frequencyMinutes,
|
||||
enableVisionLlm,
|
||||
allConnectors,
|
||||
viewingAccountsType,
|
||||
viewingMCPList,
|
||||
isYouTubeView,
|
||||
isFromOAuth,
|
||||
setSearchQuery,
|
||||
setStartDate,
|
||||
setEndDate,
|
||||
setPeriodicEnabled,
|
||||
setFrequencyMinutes,
|
||||
setEnableVisionLlm,
|
||||
handleOpenChange,
|
||||
handleTabChange,
|
||||
handleScroll,
|
||||
handleConnectOAuth,
|
||||
handleConnectNonOAuth,
|
||||
handleCreateWebcrawler,
|
||||
handleCreateYouTubeCrawler,
|
||||
handleSubmitConnectForm,
|
||||
handleStartIndexing,
|
||||
handleSkipIndexing,
|
||||
handleStartEdit,
|
||||
handleSaveConnector,
|
||||
handleDisconnectConnector,
|
||||
handleBackFromEdit,
|
||||
handleBackFromConnect,
|
||||
handleBackFromYouTube,
|
||||
handleViewAccountsList,
|
||||
handleBackFromAccountsList,
|
||||
handleBackFromMCPList,
|
||||
handleAddNewMCPFromList,
|
||||
handleQuickIndexConnector,
|
||||
connectorConfig,
|
||||
setConnectorConfig,
|
||||
setIndexingConnectorConfig,
|
||||
setConnectorName,
|
||||
} = useConnectorDialog();
|
||||
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
useEffect(() => {
|
||||
const onOpen = () => setPickerOpen(true);
|
||||
const onClose = () => setPickerOpen(false);
|
||||
window.addEventListener(PICKER_OPEN_EVENT, onOpen);
|
||||
window.addEventListener(PICKER_CLOSE_EVENT, onClose);
|
||||
return () => {
|
||||
window.removeEventListener(PICKER_OPEN_EVENT, onOpen);
|
||||
window.removeEventListener(PICKER_CLOSE_EVENT, onClose);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const {
|
||||
connectors: connectorsFromSync = [],
|
||||
loading: connectorsLoading,
|
||||
error: connectorsError,
|
||||
refreshConnectors: refreshConnectorsSync,
|
||||
} = useConnectorsSync(searchSpaceId);
|
||||
|
||||
const useSyncData = connectorsFromSync.length > 0 || (connectorsLoading && !connectorsError);
|
||||
const connectors = useSyncData ? connectorsFromSync : allConnectors || [];
|
||||
|
||||
const refreshConnectors = async () => {
|
||||
if (useSyncData) {
|
||||
await refreshConnectorsSync();
|
||||
}
|
||||
};
|
||||
|
||||
// Track indexing state locally - clears automatically when last_indexed_at changes via real-time sync
|
||||
// Also clears when failed notifications are detected
|
||||
const { indexingConnectorIds, startIndexing, stopIndexing } = useIndexingConnectors(
|
||||
connectors as SearchSourceConnector[],
|
||||
inboxItems
|
||||
);
|
||||
|
||||
// Get document types that have documents in the search space
|
||||
const activeDocumentTypes = documentTypeCounts
|
||||
? Object.entries(documentTypeCounts).filter(([, count]) => count > 0)
|
||||
: [];
|
||||
|
||||
const hasConnectors = connectors.length > 0;
|
||||
const hasSources = hasConnectors || activeDocumentTypes.length > 0;
|
||||
const totalSourceCount = connectors.length + activeDocumentTypes.length;
|
||||
|
||||
const activeConnectorsCount = connectors.length;
|
||||
|
||||
// Check which connectors are already connected
|
||||
// Real-time connector updates via Zero sync
|
||||
const connectedTypes = new Set<string>(
|
||||
(connectors || []).map((c: SearchSourceConnector) => c.connector_type)
|
||||
);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
open: () => handleOpenChange(true),
|
||||
}));
|
||||
|
||||
if (!searchSpaceId) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} modal={false} onOpenChange={handleOpenChange}>
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
|
||||
aria-hidden="true"
|
||||
onClick={() => {
|
||||
if (!pickerOpen) handleOpenChange(false);
|
||||
}}
|
||||
/>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
<DialogContent
|
||||
onFocusOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => {
|
||||
if (pickerOpen) e.preventDefault();
|
||||
}}
|
||||
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 ring-0 dark:ring-0 [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 sm:[&>button]:top-10 [&>button]:opacity-80 [&>button]:hover:opacity-100 [&>button]:hover:bg-accent [&>button]:hover:text-accent-foreground [&>button>svg]:size-5 select-none"
|
||||
>
|
||||
<DialogTitle className="sr-only">Manage Connectors</DialogTitle>
|
||||
{/* YouTube Crawler View - shown when adding YouTube videos */}
|
||||
{isYouTubeView && searchSpaceId ? (
|
||||
<YouTubeCrawlerView searchSpaceId={searchSpaceId} onBack={handleBackFromYouTube} />
|
||||
) : viewingMCPList ? (
|
||||
<ConnectorAccountsListView
|
||||
connectorType="MCP_CONNECTOR"
|
||||
connectorTitle="MCP Connectors"
|
||||
connectors={(allConnectors || []) as SearchSourceConnector[]}
|
||||
indexingConnectorIds={indexingConnectorIds}
|
||||
onBack={handleBackFromMCPList}
|
||||
onManage={handleStartEdit}
|
||||
onAddAccount={handleAddNewMCPFromList}
|
||||
addButtonText="Add New MCP Server"
|
||||
/>
|
||||
) : viewingAccountsType ? (
|
||||
<ConnectorAccountsListView
|
||||
connectorType={viewingAccountsType.connectorType}
|
||||
connectorTitle={viewingAccountsType.connectorTitle}
|
||||
connectors={(connectors || []) as SearchSourceConnector[]}
|
||||
indexingConnectorIds={indexingConnectorIds}
|
||||
onBack={handleBackFromAccountsList}
|
||||
onManage={handleStartEdit}
|
||||
onAddAccount={() => {
|
||||
// Check both OAUTH_CONNECTORS and COMPOSIO_CONNECTORS
|
||||
const oauthConnector =
|
||||
OAUTH_CONNECTORS.find(
|
||||
(c) => c.connectorType === viewingAccountsType.connectorType
|
||||
) ||
|
||||
COMPOSIO_CONNECTORS.find(
|
||||
(c) => c.connectorType === viewingAccountsType.connectorType
|
||||
);
|
||||
if (oauthConnector) {
|
||||
handleConnectOAuth(oauthConnector);
|
||||
}
|
||||
}}
|
||||
isConnecting={connectingId !== null}
|
||||
/>
|
||||
) : connectingConnectorType ? (
|
||||
<ConnectorConnectView
|
||||
connectorType={connectingConnectorType}
|
||||
onSubmit={(formData) => handleSubmitConnectForm(formData, startIndexing)}
|
||||
onBack={handleBackFromConnect}
|
||||
isSubmitting={isCreatingConnector}
|
||||
/>
|
||||
) : editingConnector ? (
|
||||
<ConnectorEditView
|
||||
connector={{
|
||||
...editingConnector,
|
||||
config: connectorConfig || editingConnector.config,
|
||||
name: editingConnector.name,
|
||||
// Sync last_indexed_at with live data from real-time sync
|
||||
last_indexed_at:
|
||||
(connectors as SearchSourceConnector[]).find((c) => c.id === editingConnector.id)
|
||||
?.last_indexed_at ?? editingConnector.last_indexed_at,
|
||||
}}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
periodicEnabled={periodicEnabled}
|
||||
frequencyMinutes={frequencyMinutes}
|
||||
enableVisionLlm={enableVisionLlm}
|
||||
isSaving={isSaving}
|
||||
isDisconnecting={isDisconnecting}
|
||||
isIndexing={indexingConnectorIds.has(editingConnector.id)}
|
||||
searchSpaceId={searchSpaceId?.toString()}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
onPeriodicEnabledChange={setPeriodicEnabled}
|
||||
onFrequencyChange={setFrequencyMinutes}
|
||||
onEnableVisionLlmChange={setEnableVisionLlm}
|
||||
onSave={() => {
|
||||
startIndexing(editingConnector.id);
|
||||
handleSaveConnector(() => refreshConnectors());
|
||||
}}
|
||||
onDisconnect={() => handleDisconnectConnector(() => refreshConnectors())}
|
||||
onBack={handleBackFromEdit}
|
||||
onQuickIndex={(() => {
|
||||
const cfg = connectorConfig || editingConnector.config;
|
||||
const isDriveOrOneDrive =
|
||||
editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" ||
|
||||
editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" ||
|
||||
editingConnector.connector_type === "ONEDRIVE_CONNECTOR" ||
|
||||
editingConnector.connector_type === "DROPBOX_CONNECTOR";
|
||||
const hasDriveItems = isDriveOrOneDrive
|
||||
? ((cfg?.selected_folders as unknown[]) ?? []).length > 0 ||
|
||||
((cfg?.selected_files as unknown[]) ?? []).length > 0
|
||||
: true;
|
||||
if (!hasDriveItems) return undefined;
|
||||
return () => {
|
||||
startIndexing(editingConnector.id);
|
||||
handleQuickIndexConnector(
|
||||
editingConnector.id,
|
||||
editingConnector.connector_type,
|
||||
stopIndexing,
|
||||
startDate,
|
||||
endDate
|
||||
);
|
||||
};
|
||||
})()}
|
||||
onConfigChange={setConnectorConfig}
|
||||
onNameChange={setConnectorName}
|
||||
/>
|
||||
) : indexingConfig ? (
|
||||
<IndexingConfigurationView
|
||||
config={indexingConfig}
|
||||
connector={
|
||||
indexingConnector
|
||||
? {
|
||||
...indexingConnector,
|
||||
config: indexingConnectorConfig || indexingConnector.config,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
periodicEnabled={periodicEnabled}
|
||||
frequencyMinutes={frequencyMinutes}
|
||||
enableVisionLlm={enableVisionLlm}
|
||||
isStartingIndexing={isStartingIndexing}
|
||||
isFromOAuth={isFromOAuth}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
onPeriodicEnabledChange={setPeriodicEnabled}
|
||||
onFrequencyChange={setFrequencyMinutes}
|
||||
onEnableVisionLlmChange={setEnableVisionLlm}
|
||||
onConfigChange={setIndexingConnectorConfig}
|
||||
onStartIndexing={() => {
|
||||
if (indexingConfig.connectorId) {
|
||||
startIndexing(indexingConfig.connectorId);
|
||||
}
|
||||
handleStartIndexing(() => refreshConnectors());
|
||||
}}
|
||||
onSkip={handleSkipIndexing}
|
||||
/>
|
||||
) : (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={handleTabChange}
|
||||
className="flex-1 flex flex-col min-h-0"
|
||||
>
|
||||
{/* Header */}
|
||||
<ConnectorDialogHeader
|
||||
activeTab={activeTab}
|
||||
totalSourceCount={activeConnectorsCount}
|
||||
searchQuery={searchQuery}
|
||||
onTabChange={handleTabChange}
|
||||
onSearchChange={setSearchQuery}
|
||||
isScrolled={isScrolled}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-h-0 relative overflow-hidden">
|
||||
<div className="h-full overflow-y-auto" onScroll={handleScroll}>
|
||||
<div className="px-4 sm:px-12 py-4 sm:py-8 pb-12 sm:pb-16">
|
||||
<TabsContent value="all" className="m-0">
|
||||
<AllConnectorsTab
|
||||
searchQuery={searchQuery}
|
||||
searchSpaceId={searchSpaceId}
|
||||
connectedTypes={connectedTypes}
|
||||
connectingId={connectingId}
|
||||
allConnectors={connectors}
|
||||
documentTypeCounts={documentTypeCounts}
|
||||
indexingConnectorIds={indexingConnectorIds}
|
||||
onConnectOAuth={handleConnectOAuth}
|
||||
onConnectNonOAuth={handleConnectNonOAuth}
|
||||
onCreateWebcrawler={handleCreateWebcrawler}
|
||||
onCreateYouTubeCrawler={handleCreateYouTubeCrawler}
|
||||
onManage={handleStartEdit}
|
||||
onViewAccountsList={handleViewAccountsList}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<ActiveConnectorsTab
|
||||
searchQuery={searchQuery}
|
||||
hasSources={hasSources}
|
||||
totalSourceCount={totalSourceCount}
|
||||
activeDocumentTypes={activeDocumentTypes}
|
||||
connectors={connectors as SearchSourceConnector[]}
|
||||
indexingConnectorIds={indexingConnectorIds}
|
||||
onTabChange={handleTabChange}
|
||||
onManage={handleStartEdit}
|
||||
onViewAccountsList={handleViewAccountsList}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Bottom fade shadow */}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-7 bg-linear-to-t from-popover via-popover/80 to-transparent pointer-events-none z-10" />
|
||||
</div>
|
||||
</Tabs>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ConnectorIndicator.displayName = "ConnectorIndicator";
|
||||
|
|
@ -31,10 +31,10 @@ export const ConnectorDialogHeader: FC<ConnectorDialogHeaderProps> = ({
|
|||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl sm:text-3xl font-semibold tracking-tight">
|
||||
Manage Connectors
|
||||
MCP Connectors
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-base text-muted-foreground/80 mt-1 sm:mt-1.5">
|
||||
Connect Surfsense to your favorite tools and services.
|
||||
Connect external tools and services through MCP.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
|
|||
|
|
@ -293,7 +293,7 @@ export const GithubConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting
|
|||
{/* Documentation Link */}
|
||||
<div>
|
||||
<Link
|
||||
href="/docs/connectors/github"
|
||||
href="/docs/connectors/external/github"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs sm:text-sm font-medium underline underline-offset-4 hover:text-primary transition-colors inline-flex items-center gap-1.5"
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ export const MCPConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting })
|
|||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
|
||||
<Server className="h-4 w-4 shrink-0" />
|
||||
<AlertDescription className="text-[10px] sm:text-xs">
|
||||
Connect to an MCP (Model Context Protocol) server. Each MCP server is added as a separate
|
||||
Connect to a Model Context Protocol server. Each MCP server is added as a separate
|
||||
connector.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
|
@ -275,8 +275,8 @@ export const MCPConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting })
|
|||
<div className="mt-3 pt-3 border-t border-green-500/20">
|
||||
<p className="font-semibold mb-2">Available tools:</p>
|
||||
<ul className="list-disc list-inside text-xs space-y-0.5">
|
||||
{testResult.tools.map((tool, i) => (
|
||||
<li key={i}>{tool.name}</li>
|
||||
{testResult.tools.map((tool) => (
|
||||
<li key={tool.name}>{tool.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -159,17 +159,17 @@ export const ObsidianConnectForm: FC<ConnectFormProps> = ({ onBack }) => {
|
|||
|
||||
<div className="h-px bg-border/60" />
|
||||
|
||||
{/* Step 4 — Pick search space */}
|
||||
{/* Step 4 — Pick workspace */}
|
||||
<article>
|
||||
<header className="mb-3 flex items-center gap-2">
|
||||
<div className="flex size-7 items-center justify-center rounded-md border border-slate-400/30 text-xs font-medium">
|
||||
4
|
||||
</div>
|
||||
<h3 className="text-sm font-medium sm:text-base">Pick this search space</h3>
|
||||
<h3 className="text-sm font-medium sm:text-base">Pick this workspace</h3>
|
||||
</header>
|
||||
<p className="text-[11px] text-muted-foreground sm:text-xs">
|
||||
In the plugin's <span className="font-medium">Search space</span> setting, choose the
|
||||
search space you want this vault to sync into. The connector will appear here
|
||||
workspace you want this vault to sync into. The connector will appear here
|
||||
automatically once the plugin makes its first sync.
|
||||
</p>
|
||||
</article>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ export function getConnectorBenefits(connectorType: string): string[] | null {
|
|||
LINEAR_CONNECTOR: [
|
||||
"Search through all your Linear issues and comments",
|
||||
"Access issue titles, descriptions, and full discussion threads",
|
||||
"Connect your team's project management directly to your search space",
|
||||
"Connect your team's project management directly to your workspace",
|
||||
"Keep your search results up-to-date with latest Linear content",
|
||||
"Index your Linear issues for enhanced search capabilities",
|
||||
],
|
||||
|
|
@ -36,63 +36,63 @@ export function getConnectorBenefits(connectorType: string): string[] | null {
|
|||
SLACK_CONNECTOR: [
|
||||
"Search through all your Slack messages and conversations",
|
||||
"Access messages from public and private channels",
|
||||
"Connect your team's communications directly to your search space",
|
||||
"Connect your team's communications directly to your workspace",
|
||||
"Keep your search results up-to-date with latest Slack content",
|
||||
"Index your Slack conversations for enhanced search capabilities",
|
||||
],
|
||||
DISCORD_CONNECTOR: [
|
||||
"Search through all your Discord messages and conversations",
|
||||
"Access messages from all accessible channels",
|
||||
"Connect your community's communications directly to your search space",
|
||||
"Connect your community's communications directly to your workspace",
|
||||
"Keep your search results up-to-date with latest Discord content",
|
||||
"Index your Discord conversations for enhanced search capabilities",
|
||||
],
|
||||
NOTION_CONNECTOR: [
|
||||
"Search through all your Notion pages and databases",
|
||||
"Access page content, properties, and metadata",
|
||||
"Connect your knowledge base directly to your search space",
|
||||
"Connect your knowledge base directly to your workspace",
|
||||
"Keep your search results up-to-date with latest Notion content",
|
||||
"Index your Notion workspace for enhanced search capabilities",
|
||||
],
|
||||
CONFLUENCE_CONNECTOR: [
|
||||
"Search through all your Confluence pages and spaces",
|
||||
"Access page content, comments, and attachments",
|
||||
"Connect your team's documentation directly to your search space",
|
||||
"Connect your team's documentation directly to your workspace",
|
||||
"Keep your search results up-to-date with latest Confluence content",
|
||||
"Index your Confluence workspace for enhanced search capabilities",
|
||||
],
|
||||
BOOKSTACK_CONNECTOR: [
|
||||
"Search through all your BookStack pages and books",
|
||||
"Access page content, chapters, and documentation",
|
||||
"Connect your documentation directly to your search space",
|
||||
"Connect your documentation directly to your workspace",
|
||||
"Keep your search results up-to-date with latest BookStack content",
|
||||
"Index your BookStack instance for enhanced search capabilities",
|
||||
],
|
||||
GITHUB_CONNECTOR: [
|
||||
"Search through code, issues, and documentation from GitHub repositories",
|
||||
"Access repository content, pull requests, and discussions",
|
||||
"Connect your codebase directly to your search space",
|
||||
"Connect your codebase directly to your workspace",
|
||||
"Keep your search results up-to-date with latest GitHub content",
|
||||
"Index your GitHub repositories for enhanced search capabilities",
|
||||
],
|
||||
JIRA_CONNECTOR: [
|
||||
"Search through all your Jira issues and tickets",
|
||||
"Access issue descriptions, comments, and project data",
|
||||
"Connect your project management directly to your search space",
|
||||
"Connect your project management directly to your workspace",
|
||||
"Keep your search results up-to-date with latest Jira content",
|
||||
"Index your Jira projects for enhanced search capabilities",
|
||||
],
|
||||
CLICKUP_CONNECTOR: [
|
||||
"Search through all your ClickUp tasks and projects",
|
||||
"Access task descriptions, comments, and project data",
|
||||
"Connect your task management directly to your search space",
|
||||
"Connect your task management directly to your workspace",
|
||||
"Keep your search results up-to-date with latest ClickUp content",
|
||||
"Index your ClickUp workspace for enhanced search capabilities",
|
||||
],
|
||||
LUMA_CONNECTOR: [
|
||||
"Search through all your Luma events",
|
||||
"Access event details, descriptions, and attendee information",
|
||||
"Connect your events directly to your search space",
|
||||
"Connect your events directly to your workspace",
|
||||
"Keep your search results up-to-date with latest Luma content",
|
||||
"Index your Luma events for enhanced search capabilities",
|
||||
],
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export interface CirclebackConfigProps extends ConnectorConfigProps {
|
|||
// Type-safe schema for webhook info response
|
||||
const circlebackWebhookInfoSchema = z.object({
|
||||
webhook_url: z.string(),
|
||||
search_space_id: z.number(),
|
||||
workspace_id: z.number(),
|
||||
method: z.string(),
|
||||
content_type: z.string(),
|
||||
description: z.string(),
|
||||
|
|
@ -40,12 +40,12 @@ export const CirclebackConfig: FC<CirclebackConfigProps> = ({ connector, onNameC
|
|||
const controller = new AbortController();
|
||||
|
||||
const doFetch = async () => {
|
||||
if (!connector.search_space_id) return;
|
||||
if (!connector.workspace_id) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
buildBackendUrl(`/api/v1/webhooks/circleback/${connector.search_space_id}/info`),
|
||||
buildBackendUrl(`/api/v1/webhooks/circleback/${connector.workspace_id}/info`),
|
||||
{ signal: controller.signal }
|
||||
);
|
||||
if (controller.signal.aborted) return;
|
||||
|
|
@ -70,7 +70,7 @@ export const CirclebackConfig: FC<CirclebackConfigProps> = ({ connector, onNameC
|
|||
|
||||
doFetch().catch(() => {});
|
||||
return () => controller.abort();
|
||||
}, [connector.search_space_id]);
|
||||
}, [connector.workspace_id]);
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setName(value);
|
||||
|
|
@ -165,7 +165,7 @@ export const CirclebackConfig: FC<CirclebackConfigProps> = ({ connector, onNameC
|
|||
<AlertDescription>
|
||||
Configure this URL in Circleback Settings → Automations → Create automation → Send
|
||||
webhook request. The webhook will automatically send meeting notes, transcripts, and
|
||||
action items to this search space.
|
||||
action items to this workspace.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
|
||||
interface ComposioCalendarConfigProps {
|
||||
connector: SearchSourceConnector;
|
||||
onConfigChange?: (config: Record<string, unknown>) => void;
|
||||
onNameChange?: (name: string) => void;
|
||||
}
|
||||
|
||||
export const ComposioCalendarConfig: FC<ComposioCalendarConfigProps> = () => {
|
||||
return <div className="space-y-6" />;
|
||||
};
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
|
||||
interface ComposioGmailConfigProps {
|
||||
connector: SearchSourceConnector;
|
||||
onConfigChange?: (config: Record<string, unknown>) => void;
|
||||
onNameChange?: (name: string) => void;
|
||||
}
|
||||
|
||||
export const ComposioGmailConfig: FC<ComposioGmailConfigProps> = () => {
|
||||
return <div className="space-y-6" />;
|
||||
};
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
"use client";
|
||||
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { getConnectorTypeDisplay } from "@/lib/connectors/utils";
|
||||
import {
|
||||
COMPOSIO_CONNECTORS,
|
||||
CRAWLERS,
|
||||
OAUTH_CONNECTORS,
|
||||
OTHER_CONNECTORS,
|
||||
} from "../../constants/connector-constants";
|
||||
import type { ConnectorConfigProps } from "../index";
|
||||
|
||||
// Catalog descriptions keyed by connector type, reused as the capability line so
|
||||
// the copy stays in sync with the catalog cards.
|
||||
const DESCRIPTION_BY_TYPE = new Map<string, string>(
|
||||
[...OAUTH_CONNECTORS, ...COMPOSIO_CONNECTORS, ...OTHER_CONNECTORS, ...CRAWLERS].map((c) => [
|
||||
c.connectorType,
|
||||
c.description,
|
||||
])
|
||||
);
|
||||
|
||||
/**
|
||||
* Fallback manage view for live connectors that are neither MCP-backed nor have
|
||||
* a dedicated config component (native/Composio Gmail & Calendar). There is
|
||||
* nothing to configure, so we just confirm the connection and echo what the
|
||||
* agent can do — no Trusted Tools (that feature is MCP-only, see the backend
|
||||
* `_ensure_mcp_connector_for_user`).
|
||||
*/
|
||||
export const LiveConnectorConnectedCard: FC<ConnectorConfigProps> = ({ connector }) => {
|
||||
const capability =
|
||||
DESCRIPTION_BY_TYPE.get(connector.connector_type) ??
|
||||
`connect to ${getConnectorTypeDisplay(connector.connector_type)}`;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-xl border border-border bg-emerald-500/5 p-4 flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-500/10 shrink-0 mt-0.5">
|
||||
<CheckCircle2 className="size-4 text-emerald-500" />
|
||||
</div>
|
||||
<div className="text-xs sm:text-sm">
|
||||
<p className="font-medium text-xs sm:text-sm">Connected</p>
|
||||
<p className="text-muted-foreground mt-1 text-[10px] sm:text-sm">{capability}.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -6,7 +6,7 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|||
import { connectorsApiService, type ObsidianStats } from "@/lib/apis/connectors-api.service";
|
||||
import type { ConnectorConfigProps } from "../index";
|
||||
|
||||
const OBSIDIAN_SETUP_DOCS_URL = "/docs/connectors/obsidian";
|
||||
const OBSIDIAN_SETUP_DOCS_URL = "/docs/connectors/external/obsidian";
|
||||
|
||||
function formatTimestamp(value: unknown): string {
|
||||
if (typeof value !== "string" || !value) return "—";
|
||||
|
|
|
|||
|
|
@ -8,8 +8,18 @@ import { Button } from "@/components/ui/button";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { isHttpUrl } from "@/lib/url";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ConnectorConfigProps } from "../index";
|
||||
|
||||
/** Return the malformed (non-http/https) lines from a newline-separated URL list. */
|
||||
function invalidUrlLines(value: string): string[] {
|
||||
return value
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line && !isHttpUrl(line));
|
||||
}
|
||||
|
||||
export const WebcrawlerConfig: FC<ConnectorConfigProps> = ({ connector, onConfigChange }) => {
|
||||
// Initialize with existing config values
|
||||
const existingApiKey = (connector.config?.FIRECRAWL_API_KEY as string | undefined) || "";
|
||||
|
|
@ -19,6 +29,8 @@ export const WebcrawlerConfig: FC<ConnectorConfigProps> = ({ connector, onConfig
|
|||
const [initialUrls, setInitialUrls] = useState(existingUrls);
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
|
||||
const invalidUrls = invalidUrlLines(initialUrls);
|
||||
|
||||
const handleApiKeyChange = (value: string) => {
|
||||
setApiKey(value);
|
||||
if (onConfigChange) {
|
||||
|
|
@ -108,11 +120,21 @@ export const WebcrawlerConfig: FC<ConnectorConfigProps> = ({ connector, onConfig
|
|||
placeholder="https://example.com https://docs.example.com https://blog.example.com"
|
||||
value={initialUrls}
|
||||
onChange={(e) => handleUrlsChange(e.target.value)}
|
||||
className="min-h-[100px] font-mono text-xs sm:text-sm bg-slate-400/5 dark:bg-white/5 border-slate-400/20 resize-none"
|
||||
aria-invalid={invalidUrls.length > 0}
|
||||
className={cn(
|
||||
"min-h-[100px] font-mono text-xs sm:text-sm bg-slate-400/5 dark:bg-white/5 border-slate-400/20 resize-none",
|
||||
invalidUrls.length > 0 && "border-destructive"
|
||||
)}
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
Enter URLs to crawl (one per line). You can add more URLs later.
|
||||
</p>
|
||||
{invalidUrls.length > 0 ? (
|
||||
<p className="text-[10px] sm:text-xs text-destructive">
|
||||
Not valid http(s) URLs: {invalidUrls.join(", ")}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
Enter URLs to crawl (one per line). You can add more URLs later.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info Alert */}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export interface ConnectorConfigProps {
|
|||
connector: SearchSourceConnector;
|
||||
onConfigChange?: (config: Record<string, unknown>) => void;
|
||||
onNameChange?: (name: string) => void;
|
||||
searchSpaceId?: string;
|
||||
workspaceId?: string;
|
||||
}
|
||||
|
||||
export type ConnectorConfigComponent = FC<ConnectorConfigProps>;
|
||||
|
|
@ -55,12 +55,8 @@ const configMap: Record<string, () => Promise<{ default: FC<ConnectorConfigProps
|
|||
import("./components/obsidian-config").then((m) => ({ default: m.ObsidianConfig })),
|
||||
COMPOSIO_GOOGLE_DRIVE_CONNECTOR: () =>
|
||||
import("./components/composio-drive-config").then((m) => ({ default: m.ComposioDriveConfig })),
|
||||
COMPOSIO_GMAIL_CONNECTOR: () =>
|
||||
import("./components/composio-gmail-config").then((m) => ({ default: m.ComposioGmailConfig })),
|
||||
COMPOSIO_GOOGLE_CALENDAR_CONNECTOR: () =>
|
||||
import("./components/composio-calendar-config").then((m) => ({
|
||||
default: m.ComposioCalendarConfig,
|
||||
})),
|
||||
// Composio Gmail/Calendar have nothing to configure; the edit view falls back
|
||||
// to LiveConnectorConnectedCard for live connectors without a config here.
|
||||
};
|
||||
|
||||
const componentCache = new Map<string, ConnectorConfigComponent>();
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
|
|||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex-shrink-0 px-6 sm:px-12 pt-8 sm:pt-10">
|
||||
<div className="flex-shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
type="button"
|
||||
|
|
@ -123,7 +123,7 @@ export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
|
|||
{/* Form Content - Scrollable */}
|
||||
<div
|
||||
ref={formContainerRef}
|
||||
className="connector-connect-form-root flex-1 min-h-0 overflow-y-auto px-6 sm:px-12"
|
||||
className="connector-connect-form-root flex-1 min-h-0 overflow-y-auto"
|
||||
>
|
||||
<ConnectFormComponent
|
||||
onSubmit={onSubmit}
|
||||
|
|
@ -134,15 +134,15 @@ export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
|
|||
</div>
|
||||
|
||||
{/* Fixed Footer - Action buttons */}
|
||||
<div className="flex-shrink-0 flex items-center justify-between px-6 sm:px-12 py-6 bg-popover">
|
||||
<Button
|
||||
<div className="flex-shrink-0 flex items-center justify-end py-6 bg-transparent">
|
||||
{/* <Button
|
||||
variant="ghost"
|
||||
onClick={onBack}
|
||||
disabled={isSubmitting}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Button> */}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleFormSubmit}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { useAtomValue } from "jotai";
|
|||
import { ArrowLeft, Info, RefreshCw } from "lucide-react";
|
||||
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { activeWorkspaceIdAtom } from "@/atoms/workspaces/workspace-query.atoms";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
|
|
@ -20,6 +20,7 @@ import { PeriodicSyncConfig } from "../../components/periodic-sync-config";
|
|||
import { VisionLLMConfig } from "../../components/vision-llm-config";
|
||||
import { LIVE_CONNECTOR_TYPES } from "../../constants/connector-constants";
|
||||
import { getConnectorDisplayName } from "../../tabs/all-connectors-tab";
|
||||
import { LiveConnectorConnectedCard } from "../components/live-connector-connected-card";
|
||||
import { MCPServiceConfig } from "../components/mcp-service-config";
|
||||
import { getConnectorConfigComponent } from "../index";
|
||||
|
||||
|
|
@ -41,7 +42,7 @@ interface ConnectorEditViewProps {
|
|||
isSaving: boolean;
|
||||
isDisconnecting: boolean;
|
||||
isIndexing?: boolean;
|
||||
searchSpaceId?: string;
|
||||
workspaceId?: string;
|
||||
onStartDateChange: (date: Date | undefined) => void;
|
||||
onEndDateChange: (date: Date | undefined) => void;
|
||||
onPeriodicEnabledChange: (enabled: boolean) => void;
|
||||
|
|
@ -65,7 +66,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
isSaving,
|
||||
isDisconnecting,
|
||||
isIndexing = false,
|
||||
searchSpaceId,
|
||||
workspaceId,
|
||||
onStartDateChange,
|
||||
onEndDateChange,
|
||||
onPeriodicEnabledChange,
|
||||
|
|
@ -78,7 +79,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
onConfigChange,
|
||||
onNameChange,
|
||||
}) => {
|
||||
const searchSpaceIdAtom = useAtomValue(activeSearchSpaceIdAtom);
|
||||
const workspaceIdAtom = useAtomValue(activeWorkspaceIdAtom);
|
||||
const isAuthExpired = connector.config?.auth_expired === true;
|
||||
const reauthEndpoint = getReauthEndpoint(connector);
|
||||
const [reauthing, setReauthing] = useState(false);
|
||||
|
|
@ -91,7 +92,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
(connector.is_indexable || connector.connector_type === EnumConnectorName.OBSIDIAN_CONNECTOR);
|
||||
|
||||
const handleReauth = useCallback(async () => {
|
||||
const spaceId = searchSpaceId ?? searchSpaceIdAtom;
|
||||
const spaceId = workspaceId ?? workspaceIdAtom;
|
||||
if (!spaceId || !reauthEndpoint) return;
|
||||
setReauthing(true);
|
||||
try {
|
||||
|
|
@ -119,13 +120,18 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
} finally {
|
||||
setReauthing(false);
|
||||
}
|
||||
}, [searchSpaceId, searchSpaceIdAtom, reauthEndpoint, connector.id]);
|
||||
}, [workspaceId, workspaceIdAtom, reauthEndpoint, connector.id]);
|
||||
|
||||
// Get connector-specific config component (MCP-backed connectors use a generic view)
|
||||
const ConnectorConfigComponent = useMemo(() => {
|
||||
if (isMCPBacked) return MCPServiceConfig;
|
||||
return getConnectorConfigComponent(connector.connector_type);
|
||||
}, [connector.connector_type, isMCPBacked]);
|
||||
// Live connectors without a dedicated config (native/Composio Gmail &
|
||||
// Calendar) fall back to a simple "Connected" card instead of a blank body.
|
||||
return (
|
||||
getConnectorConfigComponent(connector.connector_type) ??
|
||||
(isLive ? LiveConnectorConnectedCard : null)
|
||||
);
|
||||
}, [connector.connector_type, isMCPBacked, isLive]);
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const [hasMoreContent, setHasMoreContent] = useState(false);
|
||||
const [showDisconnectConfirm, setShowDisconnectConfirm] = useState(false);
|
||||
|
|
@ -201,7 +207,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
{/* Fixed Header */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex-shrink-0 px-6 sm:px-12 pt-8 sm:pt-10 transition-shadow duration-200 relative z-10",
|
||||
"flex-shrink-0 transition-shadow duration-200 relative z-10",
|
||||
isScrolled && "shadow-sm"
|
||||
)}
|
||||
>
|
||||
|
|
@ -262,7 +268,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
<div className="flex-1 min-h-0 relative overflow-hidden">
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="h-full overflow-y-auto px-6 sm:px-12"
|
||||
className="h-full overflow-y-auto"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div className="space-y-6 pb-6 pt-2">
|
||||
|
|
@ -273,7 +279,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
connector={connector}
|
||||
onConfigChange={onConfigChange}
|
||||
onNameChange={onNameChange}
|
||||
searchSpaceId={searchSpaceId}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
@ -362,7 +368,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
</div>
|
||||
|
||||
{/* Fixed Footer - Action buttons */}
|
||||
<div className="flex-shrink-0 flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3 sm:gap-0 px-6 sm:px-12 py-6 sm:py-6 bg-popover">
|
||||
<div className="flex-shrink-0 flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3 sm:gap-0 py-6 sm:py-6 bg-transparent">
|
||||
{showDisconnectConfirm ? (
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 flex-1 sm:flex-initial">
|
||||
<span className="text-xs sm:text-sm text-muted-foreground sm:whitespace-nowrap">
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
|
|||
{/* Fixed Header */}
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 px-6 sm:px-12 pt-8 sm:pt-10 transition-shadow duration-200 relative z-10",
|
||||
"shrink-0 transition-shadow duration-200 relative z-10",
|
||||
isScrolled && "shadow-sm"
|
||||
)}
|
||||
>
|
||||
|
|
@ -166,7 +166,7 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
|
|||
<div className="flex-1 min-h-0 relative overflow-hidden">
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="h-full overflow-y-auto px-6 sm:px-12"
|
||||
className="h-full overflow-y-auto"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div className="space-y-6 pb-6 pt-2">
|
||||
|
|
@ -240,7 +240,7 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
|
|||
</div>
|
||||
|
||||
{/* Fixed Footer - Action buttons */}
|
||||
<div className="flex-shrink-0 flex items-center justify-end px-6 sm:px-12 py-6 bg-popover">
|
||||
<div className="flex-shrink-0 flex items-center justify-end py-6 bg-transparent">
|
||||
{isLive ? (
|
||||
<Button onClick={onSkip} className="text-xs sm:text-sm">
|
||||
Done
|
||||
|
|
|
|||
|
|
@ -0,0 +1,483 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ChevronRight, LayoutGrid, Search, TriangleAlert, X } from "lucide-react";
|
||||
import { type ReactNode, useMemo, useState } from "react";
|
||||
import { activeWorkspaceIdAtom } from "@/atoms/workspaces/workspace-query.atoms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerHandle,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/components/ui/drawer";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import { useConnectorsSync } from "@/hooks/use-connectors-sync";
|
||||
import { useZeroDocumentTypeCounts } from "@/hooks/use-zero-document-type-counts";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ConnectorConnectView } from "./connector-configs/views/connector-connect-view";
|
||||
import { ConnectorEditView } from "./connector-configs/views/connector-edit-view";
|
||||
import { IndexingConfigurationView } from "./connector-configs/views/indexing-configuration-view";
|
||||
import {
|
||||
COMPOSIO_CONNECTORS,
|
||||
getConnectorTitle,
|
||||
OAUTH_CONNECTORS,
|
||||
} from "./constants/connector-constants";
|
||||
import { type ConnectorRow, useConnectorRows } from "./hooks/use-connector-rows";
|
||||
import { useConnectorDialog } from "./hooks/use-connector-dialog";
|
||||
import { AllConnectorsTab } from "./tabs/all-connectors-tab";
|
||||
import { ConnectorAccountsListView } from "./views/connector-accounts-list-view";
|
||||
import { YouTubeCrawlerView } from "./views/youtube-crawler-view";
|
||||
|
||||
/**
|
||||
* Connector management surface (single `/connectors` route) rendered inside the
|
||||
* workspace panel. Stateful master–detail:
|
||||
* - sub-rail: Overview + a flat "Your connectors" list (only what's connected,
|
||||
* each with a live status glyph) + an "Add MCP server" action;
|
||||
* - detail pane: the flat catalog (search + cards) OR one of the existing flow
|
||||
* views (connect / edit / indexing / accounts / YouTube), reused verbatim
|
||||
* from the former dialog.
|
||||
*
|
||||
* Unconnected connectors are one click from the catalog cards, so they never
|
||||
* clutter the rail. The hook's internal `isOpen`/`connectorDialogOpenAtom` is
|
||||
* inert on a route and ignored.
|
||||
*/
|
||||
export function ConnectorsSection() {
|
||||
const workspaceId = useAtomValue(activeWorkspaceIdAtom);
|
||||
const documentTypeCounts = useZeroDocumentTypeCounts(workspaceId);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
const {
|
||||
connectingId,
|
||||
searchQuery,
|
||||
indexingConfig,
|
||||
indexingConnector,
|
||||
indexingConnectorConfig,
|
||||
editingConnector,
|
||||
connectingConnectorType,
|
||||
isCreatingConnector,
|
||||
startDate,
|
||||
endDate,
|
||||
isStartingIndexing,
|
||||
isSaving,
|
||||
isDisconnecting,
|
||||
periodicEnabled,
|
||||
frequencyMinutes,
|
||||
enableVisionLlm,
|
||||
allConnectors,
|
||||
viewingAccountsType,
|
||||
viewingMCPList,
|
||||
isYouTubeView,
|
||||
isFromOAuth,
|
||||
setSearchQuery,
|
||||
setStartDate,
|
||||
setEndDate,
|
||||
setPeriodicEnabled,
|
||||
setFrequencyMinutes,
|
||||
setEnableVisionLlm,
|
||||
handleOpenChange,
|
||||
handleConnectOAuth,
|
||||
handleConnectNonOAuth,
|
||||
handleCreateWebcrawler,
|
||||
handleCreateYouTubeCrawler,
|
||||
handleSubmitConnectForm,
|
||||
handleStartIndexing,
|
||||
handleSkipIndexing,
|
||||
handleStartEdit,
|
||||
handleSaveConnector,
|
||||
handleDisconnectConnector,
|
||||
handleBackFromEdit,
|
||||
handleBackFromConnect,
|
||||
handleBackFromYouTube,
|
||||
handleViewAccountsList,
|
||||
handleBackFromAccountsList,
|
||||
handleViewMCPList,
|
||||
handleBackFromMCPList,
|
||||
handleAddNewMCPFromList,
|
||||
handleQuickIndexConnector,
|
||||
connectorConfig,
|
||||
setConnectorConfig,
|
||||
setIndexingConnectorConfig,
|
||||
setConnectorName,
|
||||
} = useConnectorDialog();
|
||||
|
||||
const {
|
||||
connectors: connectorsFromSync = [],
|
||||
loading: connectorsLoading,
|
||||
error: connectorsError,
|
||||
refreshConnectors: refreshConnectorsSync,
|
||||
} = useConnectorsSync(workspaceId);
|
||||
|
||||
const useSyncData = connectorsFromSync.length > 0 || (connectorsLoading && !connectorsError);
|
||||
const connectors = (useSyncData ? connectorsFromSync : allConnectors || []) as SearchSourceConnector[];
|
||||
|
||||
const refreshConnectors = async () => {
|
||||
if (useSyncData) {
|
||||
await refreshConnectorsSync();
|
||||
}
|
||||
};
|
||||
|
||||
// "Your connectors" rows with live indexing health, plus the shared indexing
|
||||
// controls the flow views reuse (single source of truth via useConnectorRows).
|
||||
const {
|
||||
rows: connectedRows,
|
||||
indexingConnectorIds,
|
||||
startIndexing,
|
||||
stopIndexing,
|
||||
} = useConnectorRows(connectors);
|
||||
|
||||
const connectedTypes = useMemo(
|
||||
() => new Set<string>(connectors.map((c) => c.connector_type)),
|
||||
[connectors]
|
||||
);
|
||||
|
||||
if (!workspaceId) return null;
|
||||
|
||||
const activeConnectorType = editingConnector
|
||||
? editingConnector.connector_type
|
||||
: connectingConnectorType ||
|
||||
viewingAccountsType?.connectorType ||
|
||||
(viewingMCPList ? EnumConnectorName.MCP_CONNECTOR : null);
|
||||
|
||||
const flowView = ((): ReactNode => {
|
||||
if (isYouTubeView) {
|
||||
return <YouTubeCrawlerView workspaceId={workspaceId} onBack={handleBackFromYouTube} />;
|
||||
}
|
||||
if (viewingMCPList) {
|
||||
return (
|
||||
<ConnectorAccountsListView
|
||||
connectorType="MCP_CONNECTOR"
|
||||
connectorTitle="MCP Connectors"
|
||||
connectors={(allConnectors || []) as SearchSourceConnector[]}
|
||||
indexingConnectorIds={indexingConnectorIds}
|
||||
onBack={handleBackFromMCPList}
|
||||
onManage={handleStartEdit}
|
||||
onAddAccount={handleAddNewMCPFromList}
|
||||
addButtonText="Add New MCP Server"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (viewingAccountsType) {
|
||||
return (
|
||||
<ConnectorAccountsListView
|
||||
connectorType={viewingAccountsType.connectorType}
|
||||
connectorTitle={viewingAccountsType.connectorTitle}
|
||||
connectors={connectors}
|
||||
indexingConnectorIds={indexingConnectorIds}
|
||||
onBack={handleBackFromAccountsList}
|
||||
onManage={handleStartEdit}
|
||||
onAddAccount={() => {
|
||||
const oauthConnector =
|
||||
OAUTH_CONNECTORS.find(
|
||||
(c) => c.connectorType === viewingAccountsType.connectorType
|
||||
) ||
|
||||
COMPOSIO_CONNECTORS.find(
|
||||
(c) => c.connectorType === viewingAccountsType.connectorType
|
||||
);
|
||||
if (oauthConnector) {
|
||||
handleConnectOAuth(oauthConnector);
|
||||
}
|
||||
}}
|
||||
isConnecting={connectingId !== null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (connectingConnectorType) {
|
||||
return (
|
||||
<ConnectorConnectView
|
||||
connectorType={connectingConnectorType}
|
||||
onSubmit={(formData) => handleSubmitConnectForm(formData, startIndexing)}
|
||||
onBack={handleBackFromConnect}
|
||||
isSubmitting={isCreatingConnector}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (editingConnector) {
|
||||
return (
|
||||
<ConnectorEditView
|
||||
connector={{
|
||||
...editingConnector,
|
||||
config: connectorConfig || editingConnector.config,
|
||||
name: editingConnector.name,
|
||||
last_indexed_at:
|
||||
connectors.find((c) => c.id === editingConnector.id)?.last_indexed_at ??
|
||||
editingConnector.last_indexed_at,
|
||||
}}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
periodicEnabled={periodicEnabled}
|
||||
frequencyMinutes={frequencyMinutes}
|
||||
enableVisionLlm={enableVisionLlm}
|
||||
isSaving={isSaving}
|
||||
isDisconnecting={isDisconnecting}
|
||||
isIndexing={indexingConnectorIds.has(editingConnector.id)}
|
||||
workspaceId={workspaceId?.toString()}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
onPeriodicEnabledChange={setPeriodicEnabled}
|
||||
onFrequencyChange={setFrequencyMinutes}
|
||||
onEnableVisionLlmChange={setEnableVisionLlm}
|
||||
onSave={() => {
|
||||
startIndexing(editingConnector.id);
|
||||
handleSaveConnector(() => refreshConnectors());
|
||||
}}
|
||||
onDisconnect={() => handleDisconnectConnector(() => refreshConnectors())}
|
||||
onBack={handleBackFromEdit}
|
||||
onQuickIndex={(() => {
|
||||
const cfg = connectorConfig || editingConnector.config;
|
||||
const isDriveOrOneDrive =
|
||||
editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" ||
|
||||
editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" ||
|
||||
editingConnector.connector_type === "ONEDRIVE_CONNECTOR" ||
|
||||
editingConnector.connector_type === "DROPBOX_CONNECTOR";
|
||||
const hasDriveItems = isDriveOrOneDrive
|
||||
? ((cfg?.selected_folders as unknown[]) ?? []).length > 0 ||
|
||||
((cfg?.selected_files as unknown[]) ?? []).length > 0
|
||||
: true;
|
||||
if (!hasDriveItems) return undefined;
|
||||
return () => {
|
||||
startIndexing(editingConnector.id);
|
||||
handleQuickIndexConnector(
|
||||
editingConnector.id,
|
||||
editingConnector.connector_type,
|
||||
stopIndexing,
|
||||
startDate,
|
||||
endDate
|
||||
);
|
||||
};
|
||||
})()}
|
||||
onConfigChange={setConnectorConfig}
|
||||
onNameChange={setConnectorName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (indexingConfig) {
|
||||
return (
|
||||
<IndexingConfigurationView
|
||||
config={indexingConfig}
|
||||
connector={
|
||||
indexingConnector
|
||||
? {
|
||||
...indexingConnector,
|
||||
config: indexingConnectorConfig || indexingConnector.config,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
periodicEnabled={periodicEnabled}
|
||||
frequencyMinutes={frequencyMinutes}
|
||||
enableVisionLlm={enableVisionLlm}
|
||||
isStartingIndexing={isStartingIndexing}
|
||||
isFromOAuth={isFromOAuth}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
onPeriodicEnabledChange={setPeriodicEnabled}
|
||||
onFrequencyChange={setFrequencyMinutes}
|
||||
onEnableVisionLlmChange={setEnableVisionLlm}
|
||||
onConfigChange={setIndexingConnectorConfig}
|
||||
onStartIndexing={() => {
|
||||
if (indexingConfig.connectorId) {
|
||||
startIndexing(indexingConfig.connectorId);
|
||||
}
|
||||
handleStartIndexing(() => refreshConnectors());
|
||||
}}
|
||||
onSkip={handleSkipIndexing}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
const isFlowActive = flowView !== null;
|
||||
|
||||
const detailTitle =
|
||||
isFlowActive && activeConnectorType ? getConnectorTitle(activeConnectorType) : "Overview";
|
||||
|
||||
const hasConnected = connectedRows.length > 0;
|
||||
|
||||
// Rail navigation: clear any open flow first (handleOpenChange(false) is the
|
||||
// hook's full reset), then apply the target management action.
|
||||
const resetFlow = () => handleOpenChange(false);
|
||||
const goOverview = () => {
|
||||
setDrawerOpen(false);
|
||||
resetFlow();
|
||||
};
|
||||
const manageRow = (row: ConnectorRow) => {
|
||||
setDrawerOpen(false);
|
||||
resetFlow();
|
||||
if (row.type === EnumConnectorName.MCP_CONNECTOR) {
|
||||
handleViewMCPList();
|
||||
return;
|
||||
}
|
||||
if (row.accountCount > 1) {
|
||||
handleViewAccountsList(row.type, row.title);
|
||||
return;
|
||||
}
|
||||
handleStartEdit(row.connectors[0]);
|
||||
};
|
||||
const addMcpServer = () => {
|
||||
setDrawerOpen(false);
|
||||
resetFlow();
|
||||
handleConnectNonOAuth?.(EnumConnectorName.MCP_CONNECTOR);
|
||||
};
|
||||
|
||||
const navBtnClass = (active: boolean) =>
|
||||
cn(
|
||||
"inline-flex w-full items-center justify-start gap-3 rounded-md px-3 py-2.5 text-left text-sm font-medium transition-colors duration-150 focus:outline-none",
|
||||
active
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
);
|
||||
|
||||
const railLabel = (text: string) => (
|
||||
<p className="px-3 pb-1 pt-1 text-xs font-semibold text-muted-foreground">{text}</p>
|
||||
);
|
||||
|
||||
const renderNav = () => (
|
||||
<>
|
||||
<button type="button" onClick={goOverview} className={navBtnClass(!isFlowActive)}>
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
<span className="min-w-0 truncate">Overview</span>
|
||||
</button>
|
||||
|
||||
{hasConnected && (
|
||||
<>
|
||||
<Separator className="my-3 bg-border" />
|
||||
{railLabel("Your connectors")}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{connectedRows.map((row) => {
|
||||
const isActive = isFlowActive && activeConnectorType === row.type;
|
||||
return (
|
||||
<button
|
||||
key={row.type}
|
||||
type="button"
|
||||
onClick={() => manageRow(row)}
|
||||
className={cn(
|
||||
"inline-flex w-full items-center justify-start gap-3 rounded-md px-3 py-2 text-left text-sm transition-colors duration-150 focus:outline-none",
|
||||
isActive
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="flex size-4 shrink-0 items-center justify-center">
|
||||
{getConnectorIcon(row.type, "size-4")}
|
||||
</span>
|
||||
<span className="min-w-0 truncate">{row.title}</span>
|
||||
{row.health === "syncing" ? (
|
||||
<Spinner size="xs" className="ml-auto shrink-0" />
|
||||
) : row.health === "failed" ? (
|
||||
<TriangleAlert
|
||||
className="ml-auto h-3.5 w-3.5 shrink-0 text-destructive"
|
||||
aria-label={row.errorMessage ?? "Indexing failed"}
|
||||
/>
|
||||
) : row.accountCount > 1 ? (
|
||||
<span className="ml-auto shrink-0 text-[11px] tabular-nums text-muted-foreground">
|
||||
{row.accountCount}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Separator className="my-3 bg-border" />
|
||||
<button type="button" onClick={addMcpServer} className={navBtnClass(false)}>
|
||||
{getConnectorIcon(EnumConnectorName.MCP_CONNECTOR, "h-4 w-4")}
|
||||
<span className="min-w-0 truncate">Add MCP server</span>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
|
||||
const catalog = (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="w-full">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 h-4.5 w-4.5 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
placeholder="Search connectors"
|
||||
className="h-10 border-0 bg-muted pl-10 pr-9 text-base shadow-none"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => setSearchQuery("")}
|
||||
className="absolute right-2 top-1/2 h-7 w-7 -translate-y-1/2 rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X className="h-4.5 w-4.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AllConnectorsTab
|
||||
searchQuery={searchQuery}
|
||||
workspaceId={workspaceId}
|
||||
connectedTypes={connectedTypes}
|
||||
connectingId={connectingId}
|
||||
allConnectors={connectors}
|
||||
documentTypeCounts={documentTypeCounts}
|
||||
indexingConnectorIds={indexingConnectorIds}
|
||||
onConnectOAuth={handleConnectOAuth}
|
||||
onConnectNonOAuth={handleConnectNonOAuth}
|
||||
onCreateWebcrawler={handleCreateWebcrawler}
|
||||
onCreateYouTubeCrawler={handleCreateYouTubeCrawler}
|
||||
onManage={handleStartEdit}
|
||||
onViewAccountsList={handleViewAccountsList}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="flex h-full min-h-[min(680px,calc(100vh-5rem))] w-full select-none flex-col gap-6 md:flex-row">
|
||||
<div className="md:w-[220px] md:shrink-0">
|
||||
<h1 className="mb-4 px-1 text-xl font-semibold tracking-tight text-foreground md:text-2xl">
|
||||
Connectors
|
||||
</h1>
|
||||
<nav className="hidden flex-col gap-0.5 md:flex">{renderNav()}</nav>
|
||||
<Drawer open={drawerOpen} onOpenChange={setDrawerOpen} shouldScaleBackground={false}>
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="flex h-10 w-full justify-between bg-transparent px-3 hover:bg-accent md:hidden"
|
||||
>
|
||||
<span className="truncate">{detailTitle}</span>
|
||||
<ChevronRight className="h-4 w-4 rotate-90 text-muted-foreground" />
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent className="h-[88vh] overflow-hidden rounded-t-2xl border bg-popover text-popover-foreground">
|
||||
<DrawerHandle className="mt-3 h-1.5 w-10" />
|
||||
<DrawerTitle className="sr-only">Connectors navigation</DrawerTitle>
|
||||
<nav className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-4">
|
||||
{renderNav()}
|
||||
</nav>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="hidden md:block">
|
||||
<h2 className="text-lg font-semibold">{detailTitle}</h2>
|
||||
<Separator className="mt-4 bg-border" />
|
||||
</div>
|
||||
<div className="min-w-0 pt-4 md:max-w-5xl">{isFlowActive ? flowView : catalog}</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,37 @@
|
|||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
|
||||
/**
|
||||
* Connectors retired during the MCP migration (no viable official MCP server).
|
||||
*
|
||||
* UI behavior: a deprecated connector is hidden from the catalog entirely unless
|
||||
* the user already connected it before deprecation — in that case it still shows
|
||||
* as a normal card (inline, no "Deprecated" section) so they can manage/disconnect
|
||||
* it. New connections are refused by the backend `/add` routes with HTTP 410.
|
||||
* Reinstate by removing the type here and in the backend `DEPRECATED_CONNECTOR_TYPES`
|
||||
* if demand returns.
|
||||
*
|
||||
* Full deprecated list (for devs):
|
||||
* - DISCORD_CONNECTOR, TEAMS_CONNECTOR, LUMA_CONNECTOR
|
||||
* - Search APIs (superseded by the Google-only web-search consolidation; the
|
||||
* google_search subagent handles public web search, and Tavily/Linkup can
|
||||
* still be added via the generic Custom MCP connector with API-key headers):
|
||||
* TAVILY_API, SEARXNG_API, LINKUP_API, BAIDU_SEARCH_API
|
||||
* - Legacy content crawlers/search (superseded by the file Import menu and
|
||||
* hosted MCP tooling): YOUTUBE_CONNECTOR, WEBCRAWLER_CONNECTOR, ELASTICSEARCH_CONNECTOR
|
||||
*/
|
||||
export const DEPRECATED_CONNECTOR_TYPES = new Set<string>([
|
||||
EnumConnectorName.DISCORD_CONNECTOR,
|
||||
EnumConnectorName.TEAMS_CONNECTOR,
|
||||
EnumConnectorName.LUMA_CONNECTOR,
|
||||
EnumConnectorName.TAVILY_API,
|
||||
EnumConnectorName.SEARXNG_API,
|
||||
EnumConnectorName.LINKUP_API,
|
||||
EnumConnectorName.BAIDU_SEARCH_API,
|
||||
EnumConnectorName.YOUTUBE_CONNECTOR,
|
||||
EnumConnectorName.WEBCRAWLER_CONNECTOR,
|
||||
EnumConnectorName.ELASTICSEARCH_CONNECTOR,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Connectors that operate in real time (no background indexing).
|
||||
|
|
@ -17,6 +50,9 @@ export const LIVE_CONNECTOR_TYPES = new Set<string>([
|
|||
EnumConnectorName.GOOGLE_GMAIL_CONNECTOR,
|
||||
EnumConnectorName.COMPOSIO_GMAIL_CONNECTOR,
|
||||
EnumConnectorName.LUMA_CONNECTOR,
|
||||
// Migrated to hosted MCP: real-time agent tools, no background indexing.
|
||||
EnumConnectorName.NOTION_CONNECTOR,
|
||||
EnumConnectorName.CONFLUENCE_CONNECTOR,
|
||||
]);
|
||||
|
||||
// OAuth Connectors (Quick Connect)
|
||||
|
|
@ -55,9 +91,9 @@ export const OAUTH_CONNECTORS = [
|
|||
{
|
||||
id: "notion-connector",
|
||||
title: "Notion",
|
||||
description: "Search your Notion pages",
|
||||
description: "Search, read, and create pages",
|
||||
connectorType: EnumConnectorName.NOTION_CONNECTOR,
|
||||
authEndpoint: "/api/v1/auth/notion/connector/add",
|
||||
authEndpoint: "/api/v1/auth/mcp/notion/connector/add/",
|
||||
},
|
||||
{
|
||||
id: "linear-connector",
|
||||
|
|
@ -104,16 +140,16 @@ export const OAUTH_CONNECTORS = [
|
|||
{
|
||||
id: "jira-connector",
|
||||
title: "Jira",
|
||||
description: "Rework in progress.",
|
||||
description: "Search, read, and manage issues",
|
||||
connectorType: EnumConnectorName.JIRA_CONNECTOR,
|
||||
authEndpoint: "/api/v1/auth/mcp/jira/connector/add/",
|
||||
},
|
||||
{
|
||||
id: "confluence-connector",
|
||||
title: "Confluence",
|
||||
description: "Rework in progress.",
|
||||
description: "Search, read, and create pages",
|
||||
connectorType: EnumConnectorName.CONFLUENCE_CONNECTOR,
|
||||
authEndpoint: "/api/v1/auth/confluence/connector/add/",
|
||||
authEndpoint: "/api/v1/auth/mcp/confluence/connector/add/",
|
||||
},
|
||||
{
|
||||
id: "clickup-connector",
|
||||
|
|
@ -243,36 +279,35 @@ export function getConnectorTitle(connectorType: string): string {
|
|||
);
|
||||
}
|
||||
|
||||
/** One row per connected connector type (multiple accounts collapsed into one). */
|
||||
export interface ConnectorTypeRow {
|
||||
type: string;
|
||||
title: string;
|
||||
connectors: SearchSourceConnector[];
|
||||
accountCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Primary way a user interacts with a connector.
|
||||
* Drives the two top-level groupings in the connector catalog UI.
|
||||
* Collapse a flat connector list into one row per `connector_type`, titled for
|
||||
* display (MCP collapses to "MCP Servers") and sorted alphabetically. Shared by
|
||||
* the connectors panel's "Your connectors" rail and the composer add-menu so the
|
||||
* two lists never drift; callers layer their own extras (e.g. health) on top.
|
||||
*/
|
||||
export type ConnectorCategory = "knowledge_base" | "tools_live";
|
||||
|
||||
export const CONNECTOR_CATEGORY_LABELS: Record<ConnectorCategory, string> = {
|
||||
knowledge_base: "Knowledge Base",
|
||||
tools_live: "Tools & Live Sources",
|
||||
};
|
||||
|
||||
const KNOWLEDGE_BASE_CONNECTOR_TYPES = new Set<string>([
|
||||
EnumConnectorName.GOOGLE_DRIVE_CONNECTOR,
|
||||
EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR,
|
||||
EnumConnectorName.ONEDRIVE_CONNECTOR,
|
||||
EnumConnectorName.DROPBOX_CONNECTOR,
|
||||
EnumConnectorName.NOTION_CONNECTOR,
|
||||
EnumConnectorName.CONFLUENCE_CONNECTOR,
|
||||
EnumConnectorName.YOUTUBE_CONNECTOR,
|
||||
EnumConnectorName.WEBCRAWLER_CONNECTOR,
|
||||
EnumConnectorName.BOOKSTACK_CONNECTOR,
|
||||
EnumConnectorName.GITHUB_CONNECTOR,
|
||||
EnumConnectorName.ELASTICSEARCH_CONNECTOR,
|
||||
EnumConnectorName.CIRCLEBACK_CONNECTOR,
|
||||
EnumConnectorName.OBSIDIAN_CONNECTOR,
|
||||
]);
|
||||
|
||||
/** Unmapped connectors surface under Tools & Live Sources. */
|
||||
export function getConnectorCategory(connectorType: string): ConnectorCategory {
|
||||
return KNOWLEDGE_BASE_CONNECTOR_TYPES.has(connectorType) ? "knowledge_base" : "tools_live";
|
||||
export function groupConnectorsByType(connectors: SearchSourceConnector[]): ConnectorTypeRow[] {
|
||||
const byType = new Map<string, SearchSourceConnector[]>();
|
||||
for (const c of connectors) {
|
||||
const arr = byType.get(c.connector_type) ?? [];
|
||||
arr.push(c);
|
||||
byType.set(c.connector_type, arr);
|
||||
}
|
||||
return [...byType.entries()]
|
||||
.map(([type, list]) => ({
|
||||
type,
|
||||
title: type === EnumConnectorName.MCP_CONNECTOR ? "MCP Servers" : getConnectorTitle(type),
|
||||
connectors: list,
|
||||
accountCount: list.length,
|
||||
}))
|
||||
.sort((a, b) => a.title.localeCompare(b.title));
|
||||
}
|
||||
|
||||
// Composio Toolkits (available integrations via Composio)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@ import { format } from "date-fns";
|
|||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
|
||||
import {
|
||||
connectorDialogOpenAtom,
|
||||
importConnectorRequestAtom,
|
||||
} from "@/atoms/connector-dialog/connector-dialog.atoms";
|
||||
import {
|
||||
createConnectorMutationAtom,
|
||||
deleteConnectorMutationAtom,
|
||||
|
|
@ -10,7 +13,7 @@ import {
|
|||
updateConnectorMutationAtom,
|
||||
} from "@/atoms/connectors/connector-mutation.atoms";
|
||||
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
|
||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { activeWorkspaceIdAtom } from "@/atoms/workspaces/workspace-query.atoms";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import { searchSourceConnector } from "@/contracts/types/connector.types";
|
||||
|
|
@ -18,8 +21,6 @@ import { OAUTH_RESULT_COOKIE, parseOAuthCallbackResult } from "@/contracts/types
|
|||
import { authenticatedFetch } from "@/lib/auth-fetch";
|
||||
import { buildBackendUrl } from "@/lib/env-config";
|
||||
import {
|
||||
trackConnectorConnected,
|
||||
trackConnectorDeleted,
|
||||
trackConnectorSetupFailure,
|
||||
trackConnectorSetupStarted,
|
||||
trackIndexWithDateRangeOpened,
|
||||
|
|
@ -58,7 +59,7 @@ function clearOAuthResultCookie(): void {
|
|||
}
|
||||
|
||||
export const useConnectorDialog = () => {
|
||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||
const workspaceId = useAtomValue(activeWorkspaceIdAtom);
|
||||
const { data: allConnectors, refetch: refetchAllConnectors } = useAtomValue(connectorsAtom);
|
||||
const { mutateAsync: indexConnector } = useAtomValue(indexConnectorMutationAtom);
|
||||
const { mutateAsync: updateConnector } = useAtomValue(updateConnectorMutationAtom);
|
||||
|
|
@ -67,6 +68,8 @@ export const useConnectorDialog = () => {
|
|||
|
||||
// Use global atom for dialog open state so it can be controlled from anywhere
|
||||
const [isOpen, setIsOpen] = useAtom(connectorDialogOpenAtom);
|
||||
// Import-flow request from the Documents sidebar "Import" menu
|
||||
const [importConnectorRequest, setImportConnectorRequest] = useAtom(importConnectorRequestAtom);
|
||||
const [activeTab, setActiveTab] = useState("all");
|
||||
const [connectingId, setConnectingId] = useState<string | null>(null);
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
|
|
@ -140,7 +143,7 @@ export const useConnectorDialog = () => {
|
|||
|
||||
const handleAutoIndex = useCallback(
|
||||
async (connector: SearchSourceConnector, connectorTitle: string, connectorType: string) => {
|
||||
if (!searchSpaceId || isAutoIndexingRef.current) return;
|
||||
if (!workspaceId || isAutoIndexingRef.current) return;
|
||||
isAutoIndexingRef.current = true;
|
||||
|
||||
const defaults = AUTO_INDEX_DEFAULTS[connectorType];
|
||||
|
|
@ -165,13 +168,13 @@ export const useConnectorDialog = () => {
|
|||
await indexConnector({
|
||||
connector_id: connector.id,
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
workspace_id: workspaceId,
|
||||
start_date: format(startDate, "yyyy-MM-dd"),
|
||||
end_date: format(endDate, "yyyy-MM-dd"),
|
||||
},
|
||||
});
|
||||
|
||||
trackIndexWithDateRangeStarted(Number(searchSpaceId), connectorType, connector.id, {
|
||||
trackIndexWithDateRangeStarted(Number(workspaceId), connectorType, connector.id, {
|
||||
hasStartDate: true,
|
||||
hasEndDate: true,
|
||||
});
|
||||
|
|
@ -188,13 +191,13 @@ export const useConnectorDialog = () => {
|
|||
});
|
||||
} finally {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.logs.summary(Number(searchSpaceId)),
|
||||
queryKey: cacheKeys.logs.summary(Number(workspaceId)),
|
||||
});
|
||||
await refetchAllConnectors();
|
||||
isAutoIndexingRef.current = false;
|
||||
}
|
||||
},
|
||||
[searchSpaceId, indexConnector, updateConnector, refetchAllConnectors]
|
||||
[workspaceId, indexConnector, updateConnector, refetchAllConnectors]
|
||||
);
|
||||
|
||||
// YouTube view state
|
||||
|
|
@ -206,7 +209,7 @@ export const useConnectorDialog = () => {
|
|||
// Consume OAuth result from cookie (set by /connectors/callback route handler)
|
||||
useEffect(() => {
|
||||
const raw = readOAuthResultCookie();
|
||||
if (!raw || !searchSpaceId) return;
|
||||
if (!raw || !workspaceId) return;
|
||||
clearOAuthResultCookie();
|
||||
|
||||
const result = parseOAuthCallbackResult(raw);
|
||||
|
|
@ -221,7 +224,7 @@ export const useConnectorDialog = () => {
|
|||
|
||||
if (oauthConnector) {
|
||||
trackConnectorSetupFailure(
|
||||
Number(searchSpaceId),
|
||||
Number(workspaceId),
|
||||
oauthConnector.connectorType,
|
||||
result.error,
|
||||
"oauth_callback"
|
||||
|
|
@ -291,11 +294,9 @@ export const useConnectorDialog = () => {
|
|||
if (newConnector && oauthConnector) {
|
||||
const connectorValidation = searchSourceConnector.safeParse(newConnector);
|
||||
if (connectorValidation.success) {
|
||||
trackConnectorConnected(
|
||||
Number(searchSpaceId),
|
||||
oauthConnector.connectorType,
|
||||
newConnector.id
|
||||
);
|
||||
// connector_connected is now emitted server-side (after_insert
|
||||
// listener in search_source_connectors_routes.py) — covers the
|
||||
// OAuth callback redirect the frontend often never observes.
|
||||
|
||||
const isLiveConnector = LIVE_CONNECTOR_TYPES.has(oauthConnector.connectorType);
|
||||
|
||||
|
|
@ -338,20 +339,20 @@ export const useConnectorDialog = () => {
|
|||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchSpaceId, handleAutoIndex, refetchAllConnectors, setIsOpen]);
|
||||
}, [workspaceId, handleAutoIndex, refetchAllConnectors, setIsOpen]);
|
||||
|
||||
// Handle OAuth connection
|
||||
const handleConnectOAuth = useCallback(
|
||||
async (connector: (typeof OAUTH_CONNECTORS)[number] | (typeof COMPOSIO_CONNECTORS)[number]) => {
|
||||
if (!searchSpaceId || !connector.authEndpoint) return;
|
||||
if (!workspaceId || !connector.authEndpoint) return;
|
||||
|
||||
// Set connecting state immediately to disable button and show spinner
|
||||
setConnectingId(connector.id);
|
||||
|
||||
trackConnectorSetupStarted(Number(searchSpaceId), connector.connectorType, "oauth_click");
|
||||
trackConnectorSetupStarted(Number(workspaceId), connector.connectorType, "oauth_click");
|
||||
|
||||
try {
|
||||
const url = buildBackendUrl(connector.authEndpoint, { space_id: searchSpaceId });
|
||||
const url = buildBackendUrl(connector.authEndpoint, { space_id: workspaceId });
|
||||
|
||||
const response = await authenticatedFetch(url, { method: "GET" });
|
||||
|
||||
|
|
@ -370,7 +371,7 @@ export const useConnectorDialog = () => {
|
|||
} catch (error) {
|
||||
console.error(`Error connecting to ${connector.title}:`, error);
|
||||
trackConnectorSetupFailure(
|
||||
Number(searchSpaceId),
|
||||
Number(workspaceId),
|
||||
connector.connectorType,
|
||||
error instanceof Error ? error.message : "oauth_initiation_failed",
|
||||
"oauth_init"
|
||||
|
|
@ -384,22 +385,22 @@ export const useConnectorDialog = () => {
|
|||
setConnectingId(null);
|
||||
}
|
||||
},
|
||||
[searchSpaceId]
|
||||
[workspaceId]
|
||||
);
|
||||
|
||||
// Handle creating YouTube crawler (not a connector, shows view in popup)
|
||||
const handleCreateYouTubeCrawler = useCallback(() => {
|
||||
if (!searchSpaceId) return;
|
||||
if (!workspaceId) return;
|
||||
setIsYouTubeView(true);
|
||||
}, [searchSpaceId]);
|
||||
}, [workspaceId]);
|
||||
|
||||
// Handle creating webcrawler connector
|
||||
const handleCreateWebcrawler = useCallback(async () => {
|
||||
if (!searchSpaceId) return;
|
||||
if (!workspaceId) return;
|
||||
|
||||
setConnectingId("webcrawler-connector");
|
||||
trackConnectorSetupStarted(
|
||||
Number(searchSpaceId),
|
||||
Number(workspaceId),
|
||||
EnumConnectorName.WEBCRAWLER_CONNECTOR,
|
||||
"webcrawler_quick_add"
|
||||
);
|
||||
|
|
@ -418,7 +419,7 @@ export const useConnectorDialog = () => {
|
|||
enable_vision_llm: false,
|
||||
},
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
workspace_id: workspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -431,12 +432,7 @@ export const useConnectorDialog = () => {
|
|||
if (connector) {
|
||||
const connectorValidation = searchSourceConnector.safeParse(connector);
|
||||
if (connectorValidation.success) {
|
||||
// Track webcrawler connector connected
|
||||
trackConnectorConnected(
|
||||
Number(searchSpaceId),
|
||||
EnumConnectorName.WEBCRAWLER_CONNECTOR,
|
||||
connector.id
|
||||
);
|
||||
// connector_connected is now emitted server-side (after_insert).
|
||||
|
||||
const config = validateIndexingConfigState({
|
||||
connectorType: EnumConnectorName.WEBCRAWLER_CONNECTOR,
|
||||
|
|
@ -453,7 +449,7 @@ export const useConnectorDialog = () => {
|
|||
} catch (error) {
|
||||
console.error("Error creating webcrawler connector:", error);
|
||||
trackConnectorSetupFailure(
|
||||
Number(searchSpaceId),
|
||||
Number(workspaceId),
|
||||
EnumConnectorName.WEBCRAWLER_CONNECTOR,
|
||||
error instanceof Error ? error.message : "webcrawler_create_failed",
|
||||
"webcrawler_quick_add"
|
||||
|
|
@ -462,18 +458,18 @@ export const useConnectorDialog = () => {
|
|||
} finally {
|
||||
setConnectingId(null);
|
||||
}
|
||||
}, [searchSpaceId, createConnector, refetchAllConnectors, setIsOpen]);
|
||||
}, [workspaceId, createConnector, refetchAllConnectors, setIsOpen]);
|
||||
|
||||
// Handle connecting non-OAuth connectors (like Tavily API, Obsidian plugin, etc.)
|
||||
const handleConnectNonOAuth = useCallback(
|
||||
(connectorType: string) => {
|
||||
if (!searchSpaceId) return;
|
||||
if (!workspaceId) return;
|
||||
|
||||
trackConnectorSetupStarted(Number(searchSpaceId), connectorType, "non_oauth_click");
|
||||
trackConnectorSetupStarted(Number(workspaceId), connectorType, "non_oauth_click");
|
||||
|
||||
setConnectingConnectorType(connectorType);
|
||||
},
|
||||
[searchSpaceId]
|
||||
[workspaceId]
|
||||
);
|
||||
|
||||
// Handle submitting connect form
|
||||
|
|
@ -495,7 +491,7 @@ export const useConnectorDialog = () => {
|
|||
},
|
||||
onIndexingStart?: (connectorId: number) => void
|
||||
) => {
|
||||
if (!searchSpaceId || !connectingConnectorType) {
|
||||
if (!workspaceId || !connectingConnectorType) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -519,7 +515,7 @@ export const useConnectorDialog = () => {
|
|||
enable_vision_llm: false,
|
||||
},
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
workspace_id: workspaceId,
|
||||
},
|
||||
});
|
||||
// Refetch connectors to get the new one
|
||||
|
|
@ -535,8 +531,7 @@ export const useConnectorDialog = () => {
|
|||
// Store connectingConnectorType before clearing it
|
||||
const currentConnectorType = connectingConnectorType;
|
||||
|
||||
// Track connector connected event for non-OAuth connectors
|
||||
trackConnectorConnected(Number(searchSpaceId), currentConnectorType, connector.id);
|
||||
// connector_connected is now emitted server-side (after_insert).
|
||||
|
||||
// Find connector title from constants
|
||||
const connectorInfo = OTHER_CONNECTORS.find(
|
||||
|
|
@ -612,7 +607,7 @@ export const useConnectorDialog = () => {
|
|||
await indexConnector({
|
||||
connector_id: connector.id,
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
workspace_id: workspaceId,
|
||||
start_date: startDateStr,
|
||||
end_date: endDateStr,
|
||||
},
|
||||
|
|
@ -631,7 +626,7 @@ export const useConnectorDialog = () => {
|
|||
setIndexingConnectorConfig(null);
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.logs.summary(Number(searchSpaceId)),
|
||||
queryKey: cacheKeys.logs.summary(Number(workspaceId)),
|
||||
});
|
||||
|
||||
await refetchAllConnectors();
|
||||
|
|
@ -684,7 +679,7 @@ export const useConnectorDialog = () => {
|
|||
} catch (error) {
|
||||
console.error("Error creating connector:", error);
|
||||
trackConnectorSetupFailure(
|
||||
Number(searchSpaceId),
|
||||
Number(workspaceId),
|
||||
connectingConnectorType ?? formData.connector_type,
|
||||
error instanceof Error ? error.message : "connector_create_failed",
|
||||
"non_oauth_form"
|
||||
|
|
@ -698,7 +693,7 @@ export const useConnectorDialog = () => {
|
|||
},
|
||||
[
|
||||
connectingConnectorType,
|
||||
searchSpaceId,
|
||||
workspaceId,
|
||||
createConnector,
|
||||
refetchAllConnectors,
|
||||
updateConnector,
|
||||
|
|
@ -724,7 +719,7 @@ export const useConnectorDialog = () => {
|
|||
// Handle viewing accounts list for OAuth connector type
|
||||
const handleViewAccountsList = useCallback(
|
||||
(connectorType: string, _connectorTitle?: string) => {
|
||||
if (!searchSpaceId) return;
|
||||
if (!workspaceId) return;
|
||||
|
||||
const oauthConnector =
|
||||
OAUTH_CONNECTORS.find((c) => c.connectorType === connectorType) ||
|
||||
|
|
@ -736,7 +731,7 @@ export const useConnectorDialog = () => {
|
|||
});
|
||||
}
|
||||
},
|
||||
[searchSpaceId]
|
||||
[workspaceId]
|
||||
);
|
||||
|
||||
// Handle going back from accounts list view
|
||||
|
|
@ -746,9 +741,9 @@ export const useConnectorDialog = () => {
|
|||
|
||||
// Handle viewing MCP list
|
||||
const handleViewMCPList = useCallback(() => {
|
||||
if (!searchSpaceId) return;
|
||||
if (!workspaceId) return;
|
||||
setViewingMCPList(true);
|
||||
}, [searchSpaceId]);
|
||||
}, [workspaceId]);
|
||||
|
||||
// Handle going back from MCP list view
|
||||
const handleBackFromMCPList = useCallback(() => {
|
||||
|
|
@ -765,7 +760,7 @@ export const useConnectorDialog = () => {
|
|||
// Handle starting indexing
|
||||
const handleStartIndexing = useCallback(
|
||||
async (refreshConnectors: () => void) => {
|
||||
if (!indexingConfig || !searchSpaceId) return;
|
||||
if (!indexingConfig || !workspaceId) return;
|
||||
|
||||
// Validate date range (skip for Google Drive, Composio Drive, OneDrive, Dropbox, and Webcrawler)
|
||||
if (
|
||||
|
|
@ -847,7 +842,7 @@ export const useConnectorDialog = () => {
|
|||
await indexConnector({
|
||||
connector_id: indexingConfig.connectorId,
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
workspace_id: workspaceId,
|
||||
},
|
||||
body: {
|
||||
folders: selectedFolders || [],
|
||||
|
|
@ -870,14 +865,14 @@ export const useConnectorDialog = () => {
|
|||
await indexConnector({
|
||||
connector_id: indexingConfig.connectorId,
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
workspace_id: workspaceId,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await indexConnector({
|
||||
connector_id: indexingConfig.connectorId,
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
workspace_id: workspaceId,
|
||||
start_date: startDateStr,
|
||||
end_date: endDateStr,
|
||||
},
|
||||
|
|
@ -886,7 +881,7 @@ export const useConnectorDialog = () => {
|
|||
|
||||
// Track index with date range started event
|
||||
trackIndexWithDateRangeStarted(
|
||||
Number(searchSpaceId),
|
||||
Number(workspaceId),
|
||||
indexingConfig.connectorType,
|
||||
indexingConfig.connectorId,
|
||||
{
|
||||
|
|
@ -898,7 +893,7 @@ export const useConnectorDialog = () => {
|
|||
// Track periodic indexing started if enabled
|
||||
if (periodicEnabled) {
|
||||
trackPeriodicIndexingStarted(
|
||||
Number(searchSpaceId),
|
||||
Number(workspaceId),
|
||||
indexingConfig.connectorType,
|
||||
indexingConfig.connectorId,
|
||||
parseInt(frequencyMinutes, 10)
|
||||
|
|
@ -915,7 +910,7 @@ export const useConnectorDialog = () => {
|
|||
|
||||
refreshConnectors();
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.logs.summary(Number(searchSpaceId)),
|
||||
queryKey: cacheKeys.logs.summary(Number(workspaceId)),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error starting indexing:", error);
|
||||
|
|
@ -926,7 +921,7 @@ export const useConnectorDialog = () => {
|
|||
},
|
||||
[
|
||||
indexingConfig,
|
||||
searchSpaceId,
|
||||
workspaceId,
|
||||
startDate,
|
||||
endDate,
|
||||
indexConnector,
|
||||
|
|
@ -951,7 +946,7 @@ export const useConnectorDialog = () => {
|
|||
// Handle starting edit mode
|
||||
const handleStartEdit = useCallback(
|
||||
(connector: SearchSourceConnector) => {
|
||||
if (!searchSpaceId) return;
|
||||
if (!workspaceId) return;
|
||||
|
||||
// For MCP connectors from "All Connectors" tab, show the list view instead of directly editing
|
||||
// (unless we're already in the MCP list view or on the Active tab where individual MCPs are shown)
|
||||
|
|
@ -986,11 +981,7 @@ export const useConnectorDialog = () => {
|
|||
|
||||
// Track index with date range opened event
|
||||
if (connector.is_indexable) {
|
||||
trackIndexWithDateRangeOpened(
|
||||
Number(searchSpaceId),
|
||||
connector.connector_type,
|
||||
connector.id
|
||||
);
|
||||
trackIndexWithDateRangeOpened(Number(workspaceId), connector.connector_type, connector.id);
|
||||
}
|
||||
|
||||
setEditingConnector(connector);
|
||||
|
|
@ -1001,13 +992,13 @@ export const useConnectorDialog = () => {
|
|||
setStartDate(undefined);
|
||||
setEndDate(undefined);
|
||||
},
|
||||
[searchSpaceId, viewingAccountsType, viewingMCPList, handleViewMCPList, activeTab]
|
||||
[workspaceId, viewingAccountsType, viewingMCPList, handleViewMCPList, activeTab]
|
||||
);
|
||||
|
||||
// Handle saving connector changes
|
||||
const handleSaveConnector = useCallback(
|
||||
async (refreshConnectors: () => void) => {
|
||||
if (!editingConnector || !searchSpaceId || isSaving) return;
|
||||
if (!editingConnector || !workspaceId || isSaving) return;
|
||||
|
||||
// Validate date range (skip for Google Drive/OneDrive/Dropbox which uses folder selection, Webcrawler which uses config, and non-indexable connectors)
|
||||
if (
|
||||
|
|
@ -1114,7 +1105,7 @@ export const useConnectorDialog = () => {
|
|||
await indexConnector({
|
||||
connector_id: editingConnector.id,
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
workspace_id: workspaceId,
|
||||
},
|
||||
body: {
|
||||
folders: selectedFolders || [],
|
||||
|
|
@ -1134,7 +1125,7 @@ export const useConnectorDialog = () => {
|
|||
await indexConnector({
|
||||
connector_id: editingConnector.id,
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
workspace_id: workspaceId,
|
||||
},
|
||||
});
|
||||
indexingDescription = "Re-indexing started with updated configuration.";
|
||||
|
|
@ -1143,7 +1134,7 @@ export const useConnectorDialog = () => {
|
|||
await indexConnector({
|
||||
connector_id: editingConnector.id,
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
workspace_id: workspaceId,
|
||||
start_date: startDateStr,
|
||||
end_date: endDateStr,
|
||||
},
|
||||
|
|
@ -1157,7 +1148,7 @@ export const useConnectorDialog = () => {
|
|||
(indexingDescription.includes("Re-indexing") || indexingDescription.includes("indexing"))
|
||||
) {
|
||||
trackIndexWithDateRangeStarted(
|
||||
Number(searchSpaceId),
|
||||
Number(workspaceId),
|
||||
editingConnector.connector_type,
|
||||
editingConnector.id,
|
||||
{
|
||||
|
|
@ -1170,7 +1161,7 @@ export const useConnectorDialog = () => {
|
|||
// Track periodic indexing if enabled
|
||||
if (periodicEnabled && editingConnector.is_indexable) {
|
||||
trackPeriodicIndexingStarted(
|
||||
Number(searchSpaceId),
|
||||
Number(workspaceId),
|
||||
editingConnector.connector_type,
|
||||
editingConnector.id,
|
||||
frequency || parseInt(frequencyMinutes, 10)
|
||||
|
|
@ -1190,7 +1181,7 @@ export const useConnectorDialog = () => {
|
|||
|
||||
refreshConnectors();
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.logs.summary(Number(searchSpaceId)),
|
||||
queryKey: cacheKeys.logs.summary(Number(workspaceId)),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error saving connector:", error);
|
||||
|
|
@ -1201,7 +1192,7 @@ export const useConnectorDialog = () => {
|
|||
},
|
||||
[
|
||||
editingConnector,
|
||||
searchSpaceId,
|
||||
workspaceId,
|
||||
isSaving,
|
||||
startDate,
|
||||
endDate,
|
||||
|
|
@ -1220,7 +1211,7 @@ export const useConnectorDialog = () => {
|
|||
// Handle disconnecting connector
|
||||
const handleDisconnectConnector = useCallback(
|
||||
async (refreshConnectors: () => void) => {
|
||||
if (!editingConnector || !searchSpaceId) return;
|
||||
if (!editingConnector || !workspaceId) return;
|
||||
|
||||
setIsDisconnecting(true);
|
||||
try {
|
||||
|
|
@ -1228,12 +1219,8 @@ export const useConnectorDialog = () => {
|
|||
id: editingConnector.id,
|
||||
});
|
||||
|
||||
// Track connector deleted event
|
||||
trackConnectorDeleted(
|
||||
Number(searchSpaceId),
|
||||
editingConnector.connector_type,
|
||||
editingConnector.id
|
||||
);
|
||||
// connector_deleted is now emitted server-side
|
||||
// (search_source_connectors_routes.delete_search_source_connector).
|
||||
|
||||
toast.success(
|
||||
editingConnector.connector_type === "MCP_CONNECTOR"
|
||||
|
|
@ -1255,7 +1242,7 @@ export const useConnectorDialog = () => {
|
|||
|
||||
refreshConnectors();
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.logs.summary(Number(searchSpaceId)),
|
||||
queryKey: cacheKeys.logs.summary(Number(workspaceId)),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error disconnecting connector:", error);
|
||||
|
|
@ -1264,7 +1251,7 @@ export const useConnectorDialog = () => {
|
|||
setIsDisconnecting(false);
|
||||
}
|
||||
},
|
||||
[editingConnector, searchSpaceId, deleteConnector, cameFromMCPList, setIsOpen]
|
||||
[editingConnector, workspaceId, deleteConnector, cameFromMCPList, setIsOpen]
|
||||
);
|
||||
|
||||
// Handle quick index (index with selected date range, or backend defaults if none selected)
|
||||
|
|
@ -1276,7 +1263,7 @@ export const useConnectorDialog = () => {
|
|||
startDate?: Date,
|
||||
endDate?: Date
|
||||
) => {
|
||||
if (!searchSpaceId) {
|
||||
if (!workspaceId) {
|
||||
if (stopIndexing) {
|
||||
stopIndexing(connectorId);
|
||||
}
|
||||
|
|
@ -1285,7 +1272,7 @@ export const useConnectorDialog = () => {
|
|||
|
||||
// Track quick index clicked event
|
||||
if (connectorType) {
|
||||
trackQuickIndexClicked(Number(searchSpaceId), connectorType, connectorId);
|
||||
trackQuickIndexClicked(Number(workspaceId), connectorType, connectorId);
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -1296,7 +1283,7 @@ export const useConnectorDialog = () => {
|
|||
await indexConnector({
|
||||
connector_id: connectorId,
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
workspace_id: workspaceId,
|
||||
start_date: startDateStr,
|
||||
end_date: endDateStr,
|
||||
},
|
||||
|
|
@ -1305,7 +1292,7 @@ export const useConnectorDialog = () => {
|
|||
|
||||
// Invalidate queries to refresh data
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.logs.summary(Number(searchSpaceId)),
|
||||
queryKey: cacheKeys.logs.summary(Number(workspaceId)),
|
||||
});
|
||||
// Note: Don't call stopIndexing here - let useIndexingConnectors hook
|
||||
// detect when last_indexed_at changes via real-time sync
|
||||
|
|
@ -1318,7 +1305,7 @@ export const useConnectorDialog = () => {
|
|||
}
|
||||
}
|
||||
},
|
||||
[searchSpaceId, indexConnector]
|
||||
[workspaceId, indexConnector]
|
||||
);
|
||||
|
||||
// Handle going back from edit view
|
||||
|
|
@ -1376,6 +1363,47 @@ export const useConnectorDialog = () => {
|
|||
[isStartingIndexing, isDisconnecting, isSaving, isCreatingConnector, setIsOpen]
|
||||
);
|
||||
|
||||
// Consume an import request from the Documents sidebar "Import" menu.
|
||||
// mode "connect" -> always OAuth connect (add account). mode "auto" routes by
|
||||
// account count: none -> connect, one -> edit view, many -> accounts list.
|
||||
useEffect(() => {
|
||||
if (!importConnectorRequest || !workspaceId) return;
|
||||
|
||||
const { connectorType, mode } = importConnectorRequest;
|
||||
setImportConnectorRequest(null);
|
||||
|
||||
const connectorDef =
|
||||
OAUTH_CONNECTORS.find((c) => c.connectorType === connectorType) ||
|
||||
COMPOSIO_CONNECTORS.find((c) => c.connectorType === connectorType);
|
||||
|
||||
const existing = (allConnectors || []).filter(
|
||||
(c: SearchSourceConnector) => c.connector_type === connectorType
|
||||
);
|
||||
|
||||
if (mode === "connect" || existing.length === 0) {
|
||||
if (connectorDef) {
|
||||
handleConnectOAuth(connectorDef);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setIsOpen(true);
|
||||
if (existing.length === 1) {
|
||||
handleStartEdit(existing[0]);
|
||||
} else {
|
||||
handleViewAccountsList(connectorType, connectorDef?.title);
|
||||
}
|
||||
}, [
|
||||
importConnectorRequest,
|
||||
workspaceId,
|
||||
allConnectors,
|
||||
setImportConnectorRequest,
|
||||
handleConnectOAuth,
|
||||
handleStartEdit,
|
||||
handleViewAccountsList,
|
||||
setIsOpen,
|
||||
]);
|
||||
|
||||
const handleTabChange = useCallback((value: string) => {
|
||||
setActiveTab(value);
|
||||
}, []);
|
||||
|
|
@ -1406,7 +1434,7 @@ export const useConnectorDialog = () => {
|
|||
periodicEnabled,
|
||||
frequencyMinutes,
|
||||
enableVisionLlm,
|
||||
searchSpaceId,
|
||||
workspaceId,
|
||||
allConnectors,
|
||||
viewingAccountsType,
|
||||
viewingMCPList,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import { connectorIndexingMetadata } from "@/contracts/types/inbox.types";
|
||||
import { type ConnectorTypeRow, groupConnectorsByType } from "../constants/connector-constants";
|
||||
import { useIndexingConnectors } from "./use-indexing-connectors";
|
||||
|
||||
/** Health of a connected connector type, derived from indexing + inbox state. */
|
||||
export type ConnectorHealth = "syncing" | "failed" | "ok";
|
||||
|
||||
/** A grouped connector row enriched with live indexing health. */
|
||||
export interface ConnectorRow extends ConnectorTypeRow {
|
||||
health: ConnectorHealth;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source of truth for the "Your connectors" list: groups the connectors
|
||||
* by type and derives each row's health (syncing > failed > ok) from the status
|
||||
* inbox and optimistic indexing state. Shared by the connectors panel rail and
|
||||
* the composer add-menu so both render identical status glyphs.
|
||||
*
|
||||
* Also returns the underlying indexing controls so the panel can reuse them for
|
||||
* its flow views instead of instantiating `useIndexingConnectors` twice.
|
||||
*/
|
||||
export function useConnectorRows(connectors: SearchSourceConnector[]) {
|
||||
const statusInboxItems = useAtomValue(statusInboxItemsAtom);
|
||||
const inboxItems = useMemo(
|
||||
() => statusInboxItems.filter((item) => item.type === "connector_indexing"),
|
||||
[statusInboxItems]
|
||||
);
|
||||
|
||||
const { indexingConnectorIds, startIndexing, stopIndexing } = useIndexingConnectors(
|
||||
connectors,
|
||||
inboxItems
|
||||
);
|
||||
|
||||
// Latest indexing status per connector id, parsed from the status inbox.
|
||||
const statusByConnectorId = useMemo(() => {
|
||||
const map = new Map<number, { status?: string; error?: string; at: string }>();
|
||||
for (const item of inboxItems) {
|
||||
const parsed = connectorIndexingMetadata.safeParse(item.metadata);
|
||||
if (!parsed.success) continue;
|
||||
const at = item.updated_at ?? item.created_at;
|
||||
const prev = map.get(parsed.data.connector_id);
|
||||
if (!prev || at > prev.at) {
|
||||
map.set(parsed.data.connector_id, {
|
||||
status: parsed.data.status,
|
||||
error: parsed.data.error_message ?? undefined,
|
||||
at,
|
||||
});
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [inboxItems]);
|
||||
|
||||
const rows = useMemo<ConnectorRow[]>(() => {
|
||||
return groupConnectorsByType(connectors).map((row) => {
|
||||
const ids = row.connectors.map((c) => c.id);
|
||||
const syncing = ids.some(
|
||||
(id) => indexingConnectorIds.has(id) || statusByConnectorId.get(id)?.status === "in_progress"
|
||||
);
|
||||
const failedEntry = syncing
|
||||
? undefined
|
||||
: ids.map((id) => statusByConnectorId.get(id)).find((s) => s?.status === "failed");
|
||||
return {
|
||||
...row,
|
||||
health: syncing ? "syncing" : failedEntry ? "failed" : "ok",
|
||||
errorMessage: failedEntry?.error,
|
||||
} satisfies ConnectorRow;
|
||||
});
|
||||
}, [connectors, indexingConnectorIds, statusByConnectorId]);
|
||||
|
||||
return { rows, indexingConnectorIds, startIndexing, stopIndexing, inboxItems };
|
||||
}
|
||||
|
|
@ -1,6 +1,3 @@
|
|||
// Main component export
|
||||
export { ConnectorIndicator } from "../connector-popup";
|
||||
|
||||
// Sub-components (if needed for external use)
|
||||
export { ConnectorCard } from "./components/connector-card";
|
||||
export { ConnectorDialogHeader } from "./components/connector-dialog-header";
|
||||
|
|
|
|||
|
|
@ -33,12 +33,13 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
|
|||
searchQuery,
|
||||
hasSources,
|
||||
activeDocumentTypes,
|
||||
connectors,
|
||||
connectors: allActiveConnectors,
|
||||
indexingConnectorIds,
|
||||
onTabChange: _onTabChange,
|
||||
onManage,
|
||||
onViewAccountsList,
|
||||
}) => {
|
||||
const connectors = allActiveConnectors;
|
||||
// Convert activeDocumentTypes array to Record for utility function
|
||||
const documentTypeCounts = activeDocumentTypes.reduce(
|
||||
(acc, [docType, count]) => {
|
||||
|
|
@ -146,7 +147,6 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
|
|||
<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">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
"use client";
|
||||
|
||||
import { Search } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useIsSelfHosted } from "@/components/providers/runtime-config";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
|
|
@ -9,10 +8,8 @@ import { usePlatform } from "@/hooks/use-platform";
|
|||
import { ConnectorCard } from "../components/connector-card";
|
||||
import {
|
||||
COMPOSIO_CONNECTORS,
|
||||
CONNECTOR_CATEGORY_LABELS,
|
||||
type ConnectorCategory,
|
||||
CRAWLERS,
|
||||
getConnectorCategory,
|
||||
DEPRECATED_CONNECTOR_TYPES,
|
||||
OAUTH_CONNECTORS,
|
||||
OTHER_CONNECTORS,
|
||||
} from "../constants/connector-constants";
|
||||
|
|
@ -43,7 +40,7 @@ export function getConnectorDisplayName(fullName: string): string {
|
|||
|
||||
interface AllConnectorsTabProps {
|
||||
searchQuery: string;
|
||||
searchSpaceId: string;
|
||||
workspaceId: string;
|
||||
connectedTypes: Set<string>;
|
||||
connectingId: string | null;
|
||||
allConnectors: SearchSourceConnector[] | undefined;
|
||||
|
|
@ -101,22 +98,19 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
|
|||
c.description.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const inCategory =
|
||||
(category: ConnectorCategory) =>
|
||||
<T extends { connectorType?: string }>(connector: T): boolean =>
|
||||
!!connector.connectorType && getConnectorCategory(connector.connectorType) === category;
|
||||
|
||||
const knowledgeBase = {
|
||||
oauth: filteredOAuth.filter(inCategory("knowledge_base")),
|
||||
composio: filteredComposio.filter(inCategory("knowledge_base")),
|
||||
other: filteredOther.filter(inCategory("knowledge_base")),
|
||||
crawlers: filteredCrawlers.filter(inCategory("knowledge_base")),
|
||||
};
|
||||
const toolsLive = {
|
||||
oauth: filteredOAuth.filter(inCategory("tools_live")),
|
||||
composio: filteredComposio.filter(inCategory("tools_live")),
|
||||
other: filteredOther.filter(inCategory("tools_live")),
|
||||
crawlers: filteredCrawlers.filter(inCategory("tools_live")),
|
||||
// One flat grid, no separate "Deprecated" section. A deprecated connector is
|
||||
// shown only if the user already connected it (before deprecation), so it
|
||||
// stays manageable/disconnectable; never-connected deprecated connectors are
|
||||
// hidden entirely. See DEPRECATED_CONNECTOR_TYPES for the full list.
|
||||
const isVisible = <T extends { connectorType?: string }>(c: T) =>
|
||||
!c.connectorType ||
|
||||
!DEPRECATED_CONNECTOR_TYPES.has(c.connectorType) ||
|
||||
connectedTypes.has(c.connectorType);
|
||||
const available = {
|
||||
oauth: filteredOAuth.filter(isVisible),
|
||||
composio: filteredComposio.filter(isVisible),
|
||||
other: filteredOther.filter(isVisible),
|
||||
crawlers: filteredCrawlers.filter(isVisible),
|
||||
};
|
||||
|
||||
const renderOAuthCard = (connector: OAuthConnector | ComposioConnector) => {
|
||||
|
|
@ -248,62 +242,28 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
|
|||
);
|
||||
};
|
||||
|
||||
const hasKnowledgeBase =
|
||||
knowledgeBase.oauth.length > 0 ||
|
||||
knowledgeBase.composio.length > 0 ||
|
||||
knowledgeBase.other.length > 0 ||
|
||||
knowledgeBase.crawlers.length > 0;
|
||||
const hasToolsLive =
|
||||
toolsLive.oauth.length > 0 ||
|
||||
toolsLive.composio.length > 0 ||
|
||||
toolsLive.other.length > 0 ||
|
||||
toolsLive.crawlers.length > 0;
|
||||
|
||||
const hasAnyResults = hasKnowledgeBase || hasToolsLive;
|
||||
const hasAnyResults =
|
||||
available.oauth.length > 0 ||
|
||||
available.composio.length > 0 ||
|
||||
available.other.length > 0 ||
|
||||
available.crawlers.length > 0;
|
||||
|
||||
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">
|
||||
{hasKnowledgeBase && (
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">
|
||||
{CONNECTOR_CATEGORY_LABELS.knowledge_base}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{knowledgeBase.oauth.map(renderOAuthCard)}
|
||||
{knowledgeBase.composio.map(renderOAuthCard)}
|
||||
{knowledgeBase.crawlers.map(renderCrawlerCard)}
|
||||
{knowledgeBase.other.map(renderOtherCard)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{hasToolsLive && (
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">
|
||||
{CONNECTOR_CATEGORY_LABELS.tools_live}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{toolsLive.oauth.map(renderOAuthCard)}
|
||||
{toolsLive.composio.map(renderOAuthCard)}
|
||||
{toolsLive.crawlers.map(renderCrawlerCard)}
|
||||
{toolsLive.other.map(renderOtherCard)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{available.oauth.map(renderOAuthCard)}
|
||||
{available.composio.map(renderOAuthCard)}
|
||||
{available.crawlers.map(renderCrawlerCard)}
|
||||
{available.other.map(renderOtherCard)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,14 +4,14 @@ import { useAtomValue } from "jotai";
|
|||
import { ArrowLeft, Plus, RefreshCw, Server } from "lucide-react";
|
||||
import { type FC, useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { activeWorkspaceIdAtom } from "@/atoms/workspaces/workspace-query.atoms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import { authenticatedFetch } from "@/lib/auth-fetch";
|
||||
import { getReauthEndpoint } from "@/lib/connector-telemetry";
|
||||
import { getReauthEndpoint, needsMcpReconnect } from "@/lib/connector-telemetry";
|
||||
import { buildBackendUrl } from "@/lib/env-config";
|
||||
import { formatRelativeDate } from "@/lib/format-date";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -44,7 +44,7 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
|
|||
isConnecting = false,
|
||||
addButtonText,
|
||||
}) => {
|
||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||
const workspaceId = useAtomValue(activeWorkspaceIdAtom);
|
||||
const [reauthingId, setReauthingId] = useState<number | null>(null);
|
||||
const [confirmDisconnectId, setConfirmDisconnectId] = useState<number | null>(null);
|
||||
const [disconnectingId, setDisconnectingId] = useState<number | null>(null);
|
||||
|
|
@ -58,13 +58,13 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
|
|||
const handleReauth = useCallback(
|
||||
async (connector: SearchSourceConnector) => {
|
||||
const endpoint = getReauthEndpoint(connector);
|
||||
if (!searchSpaceId || !endpoint) return;
|
||||
if (!workspaceId || !endpoint) return;
|
||||
setReauthingId(connector.id);
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
buildBackendUrl(endpoint, {
|
||||
connector_id: connector.id,
|
||||
space_id: searchSpaceId,
|
||||
space_id: workspaceId,
|
||||
return_url: window.location.pathname,
|
||||
})
|
||||
);
|
||||
|
|
@ -86,7 +86,7 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
|
|||
setReauthingId(null);
|
||||
}
|
||||
},
|
||||
[searchSpaceId]
|
||||
[workspaceId]
|
||||
);
|
||||
|
||||
// Filter connectors to only show those of this type
|
||||
|
|
@ -111,7 +111,7 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
|
|||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="px-6 sm:px-12 pt-8 sm:pt-10 pb-1 sm:pb-4 bg-popover">
|
||||
<div className="pb-1 sm:pb-4 bg-transparent">
|
||||
{/* Back button */}
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -165,7 +165,7 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
|
|||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto px-6 sm:px-12 pt-0 sm:pt-6 pb-6 sm:pb-8">
|
||||
<div className="flex-1 overflow-y-auto pt-0 sm:pt-6 pb-6 sm:pb-8">
|
||||
{/* Connected Accounts Grid */}
|
||||
{typeConnectors.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
|
|
@ -192,6 +192,9 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
|
|||
const connectorReauthEndpoint = getReauthEndpoint(connector);
|
||||
const isAuthExpired =
|
||||
!!connectorReauthEndpoint && connector.config?.auth_expired === true;
|
||||
// Migrated type still on legacy native config: reconnect via MCP to
|
||||
// start producing agent tools again.
|
||||
const needsReconnect = !isAuthExpired && needsMcpReconnect(connector);
|
||||
const isLive =
|
||||
LIVE_CONNECTOR_TYPES.has(connector.connector_type) ||
|
||||
Boolean(connector.config?.server_config);
|
||||
|
|
@ -245,6 +248,19 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
|
|||
/>
|
||||
Re-authenticate
|
||||
</Button>
|
||||
) : needsReconnect ? (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 text-[11px] px-3 font-medium bg-amber-600 hover:bg-amber-700 text-white border-0 shadow-xs shrink-0"
|
||||
onClick={() => handleReauth(connector)}
|
||||
disabled={reauthingId === connector.id}
|
||||
title="This connector moved to MCP. Reconnect to use it with the agent."
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn("size-3.5", reauthingId === connector.id && "animate-spin")}
|
||||
/>
|
||||
Reconnect via MCP
|
||||
</Button>
|
||||
) : isLive && onDisconnect ? (
|
||||
confirmDisconnectId === connector.id ? (
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
|
|
|
|||
|
|
@ -38,11 +38,11 @@ function extractYoutubeUrls(text: string): string[] {
|
|||
}
|
||||
|
||||
interface YouTubeCrawlerViewProps {
|
||||
searchSpaceId: string;
|
||||
workspaceId: string;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ searchSpaceId, onBack }) => {
|
||||
export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ workspaceId, onBack }) => {
|
||||
const t = useTranslations("add_youtube");
|
||||
const [videoTags, setVideoTags] = useState<TagType[]>([]);
|
||||
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
|
||||
|
|
@ -165,7 +165,7 @@ export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ searchSpaceId,
|
|||
{
|
||||
document_type: "YOUTUBE_VIDEO",
|
||||
content: videoUrls,
|
||||
search_space_id: parseInt(searchSpaceId, 10),
|
||||
workspace_id: parseInt(workspaceId, 10),
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
|
|
@ -216,7 +216,7 @@ export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ searchSpaceId,
|
|||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="shrink-0 px-6 sm:px-12 pt-8 sm:pt-10">
|
||||
<div className="shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
type="button"
|
||||
|
|
@ -239,7 +239,7 @@ export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ searchSpaceId,
|
|||
</div>
|
||||
|
||||
{/* Form Content - Scrollable */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-6 sm:px-12">
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<div className="space-y-4 pb-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="video-input" className="text-sm sm:text-base">
|
||||
|
|
@ -325,7 +325,7 @@ export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ searchSpaceId,
|
|||
</div>
|
||||
|
||||
{/* Fixed Footer - Action buttons */}
|
||||
<div className="shrink-0 flex items-center justify-between px-6 sm:px-12 py-6 bg-popover">
|
||||
<div className="shrink-0 flex items-center justify-between py-6 bg-transparent">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onBack}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { activeWorkspaceIdAtom } from "@/atoms/workspaces/workspace-query.atoms";
|
||||
import { DocumentUploadTab } from "@/components/sources/DocumentUploadTab";
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -90,9 +90,9 @@ const DocumentUploadPopupContent: FC<{
|
|||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}> = ({ isOpen, onOpenChange }) => {
|
||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||
const workspaceId = useAtomValue(activeWorkspaceIdAtom);
|
||||
|
||||
if (!searchSpaceId) return null;
|
||||
if (!workspaceId) return null;
|
||||
|
||||
const handleSuccess = () => {
|
||||
onOpenChange(false);
|
||||
|
|
@ -112,12 +112,12 @@ const DocumentUploadPopupContent: FC<{
|
|||
Upload Documents
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-base text-muted-foreground/80 line-clamp-1">
|
||||
Upload and sync your documents to your search space
|
||||
Upload and sync your documents to your workspace
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-4 sm:px-6 pb-4 sm:pb-6">
|
||||
<DocumentUploadTab searchSpaceId={searchSpaceId} onSuccess={handleSuccess} />
|
||||
<DocumentUploadTab workspaceId={workspaceId} onSuccess={handleSuccess} />
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -233,4 +233,4 @@ Image.Preview = ImagePreview;
|
|||
Image.Filename = ImageFilename;
|
||||
Image.Zoom = ImageZoom;
|
||||
|
||||
export { Image, ImageRoot, ImagePreview, ImageFilename, ImageZoom, imageVariants };
|
||||
export { Image, ImageFilename, ImagePreview, ImageRoot, ImageZoom, imageVariants };
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ const NumericChunkCitation: FC<{ chunkId: number }> = ({ chunkId }) => {
|
|||
<DrawerTitle>Citation</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<div className="min-h-0 flex-1 flex flex-col overflow-hidden">
|
||||
<CitationPanelContent chunkId={chunkId} showHeader={false} />
|
||||
<CitationPanelContent chunkId={chunkId} />
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
|
@ -115,9 +115,9 @@ interface UrlCitationProps {
|
|||
}
|
||||
|
||||
/**
|
||||
* Inline citation for live web search results (URL-based chunk IDs).
|
||||
* Inline citation for URL-based chunk IDs (e.g. scraped/linked web pages).
|
||||
* Renders a compact chip with favicon + domain and a hover popover showing the
|
||||
* page title and snippet (extracted deterministically from web_search tool results).
|
||||
* page title and snippet when citation metadata is available.
|
||||
*/
|
||||
export const UrlCitation: FC<UrlCitationProps> = ({ url }) => {
|
||||
const reactId = useId();
|
||||
|
|
|
|||
|
|
@ -733,15 +733,9 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
const editableProps = useMemo(
|
||||
() => ({
|
||||
placeholder,
|
||||
onPaste: (e: React.ClipboardEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
const text = e.clipboardData.getData("text/plain");
|
||||
const tf = editor.tf as { insertText: (value: string) => void };
|
||||
tf.insertText(text);
|
||||
},
|
||||
onKeyDown: handleKeyDown,
|
||||
}),
|
||||
[editor, handleKeyDown, placeholder]
|
||||
[handleKeyDown, placeholder]
|
||||
);
|
||||
|
||||
const mentionEditorContextValue = useMemo<MentionEditorContextValue>(
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import { useElectronAPI } from "@/hooks/use-platform";
|
|||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||
import { getVirtualPathDisplay } from "@/lib/chat/virtual-path-display";
|
||||
import { type CitationUrlMap, preprocessCitationMarkdown } from "@/lib/citations/citation-parser";
|
||||
import { getWorkspaceIdNumber } from "@/lib/route-params";
|
||||
import { tryGetHostname } from "@/lib/url";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -188,13 +189,7 @@ function FilePathLink({ path, className }: { path: string; className?: string })
|
|||
const openEditorPanel = useSetAtom(openEditorPanelAtom);
|
||||
const params = useParams();
|
||||
const electronAPI = useElectronAPI();
|
||||
const searchSpaceIdParam = params?.search_space_id;
|
||||
const parsedSearchSpaceId = Array.isArray(searchSpaceIdParam)
|
||||
? Number(searchSpaceIdParam[0])
|
||||
: Number(searchSpaceIdParam);
|
||||
const resolvedSearchSpaceId = Number.isFinite(parsedSearchSpaceId)
|
||||
? parsedSearchSpaceId
|
||||
: undefined;
|
||||
const resolvedWorkspaceId = getWorkspaceIdNumber(params);
|
||||
|
||||
const { displayName, isFolder } = getVirtualPathDisplay(path);
|
||||
const icon = isFolder ? <FolderIcon className="size-3.5" /> : <FileIcon className="size-3.5" />;
|
||||
|
|
@ -209,7 +204,7 @@ function FilePathLink({ path, className }: { path: string; className?: string })
|
|||
if (electronAPI.getAgentFilesystemMounts) {
|
||||
try {
|
||||
const mounts = (await electronAPI.getAgentFilesystemMounts(
|
||||
resolvedSearchSpaceId
|
||||
resolvedWorkspaceId
|
||||
)) as AgentFilesystemMount[];
|
||||
resolvedLocalPath = normalizeLocalVirtualPathForEditor(path, mounts);
|
||||
} catch {
|
||||
|
|
@ -220,21 +215,21 @@ function FilePathLink({ path, className }: { path: string; className?: string })
|
|||
kind: "local_file",
|
||||
localFilePath: resolvedLocalPath,
|
||||
title: resolvedLocalPath.split("/").pop() || resolvedLocalPath,
|
||||
searchSpaceId: resolvedSearchSpaceId,
|
||||
workspaceId: resolvedWorkspaceId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!resolvedSearchSpaceId || !path.startsWith("/documents/")) return;
|
||||
if (!resolvedWorkspaceId || !path.startsWith("/documents/")) return;
|
||||
try {
|
||||
const doc = await documentsApiService.getDocumentByVirtualPath({
|
||||
search_space_id: resolvedSearchSpaceId,
|
||||
workspace_id: resolvedWorkspaceId,
|
||||
virtual_path: path,
|
||||
});
|
||||
openEditorPanel({
|
||||
kind: "document",
|
||||
documentId: doc.id,
|
||||
searchSpaceId: resolvedSearchSpaceId,
|
||||
workspaceId: resolvedWorkspaceId,
|
||||
title: doc.title,
|
||||
});
|
||||
} catch {
|
||||
|
|
@ -242,7 +237,7 @@ function FilePathLink({ path, className }: { path: string; className?: string })
|
|||
}
|
||||
})();
|
||||
},
|
||||
[electronAPI, openEditorPanel, path, resolvedSearchSpaceId]
|
||||
[electronAPI, openEditorPanel, path, resolvedWorkspaceId]
|
||||
);
|
||||
|
||||
// Folders cannot open in the editor panel — keep them as visual chips.
|
||||
|
|
|
|||
24
surfsense_web/components/assistant-ui/message-timestamp.tsx
Normal file
24
surfsense_web/components/assistant-ui/message-timestamp.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { useAuiState } from "@assistant-ui/react";
|
||||
import { useAtomValue } from "jotai";
|
||||
import type { FC } from "react";
|
||||
import { showMessageTimestampsAtom } from "@/atoms/chat/show-timestamps.atom";
|
||||
import { formatMessageTimestamp } from "@/lib/format-date";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Muted, always-visible timestamp under a chat message. Renders only when the
|
||||
* user has opted in via {@link showMessageTimestampsAtom} and the message
|
||||
* carries a ``createdAt`` (absent on optimistic pre-persist messages).
|
||||
*/
|
||||
export const MessageTimestamp: FC<{ className?: string }> = ({ className }) => {
|
||||
const show = useAtomValue(showMessageTimestampsAtom);
|
||||
const createdAt = useAuiState(({ message }) => message?.createdAt);
|
||||
|
||||
if (!show || !createdAt) return null;
|
||||
|
||||
return (
|
||||
<div className={cn("select-none text-[11px] text-muted-foreground", className)}>
|
||||
{formatMessageTimestamp(createdAt)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -22,10 +22,12 @@ import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
|
|||
import { messageDocumentsMapAtom } from "@/atoms/chat/mentioned-documents.atom";
|
||||
import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
|
||||
import { MentionChip } from "@/components/assistant-ui/mention-chip";
|
||||
import { MessageTimestamp } from "@/components/assistant-ui/message-timestamp";
|
||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
|
||||
import { parseMentionSegments } from "@/lib/chat/parse-mention-segments";
|
||||
import { getWorkspaceIdNumber } from "@/lib/route-params";
|
||||
|
||||
interface AuthorMetadata {
|
||||
displayName: string | null;
|
||||
|
|
@ -75,39 +77,33 @@ const UserTextPart: FC = () => {
|
|||
const openEditorPanel = useSetAtom(openEditorPanelAtom);
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceIdParam = params?.search_space_id;
|
||||
const parsedSearchSpaceId = Array.isArray(searchSpaceIdParam)
|
||||
? Number(searchSpaceIdParam[0])
|
||||
: Number(searchSpaceIdParam);
|
||||
const resolvedSearchSpaceId = Number.isFinite(parsedSearchSpaceId)
|
||||
? parsedSearchSpaceId
|
||||
: undefined;
|
||||
const resolvedWorkspaceId = getWorkspaceIdNumber(params);
|
||||
|
||||
const handleOpenDoc = useCallback(
|
||||
(docId: number, title: string) => {
|
||||
if (!resolvedSearchSpaceId) {
|
||||
toast.error("Cannot open document outside a search space.");
|
||||
if (!resolvedWorkspaceId) {
|
||||
toast.error("Cannot open document outside a workspace.");
|
||||
return;
|
||||
}
|
||||
openEditorPanel({
|
||||
kind: "document",
|
||||
documentId: docId,
|
||||
searchSpaceId: resolvedSearchSpaceId,
|
||||
workspaceId: resolvedWorkspaceId,
|
||||
title,
|
||||
});
|
||||
},
|
||||
[openEditorPanel, resolvedSearchSpaceId]
|
||||
[openEditorPanel, resolvedWorkspaceId]
|
||||
);
|
||||
|
||||
const handleOpenThread = useCallback(
|
||||
(threadId: number) => {
|
||||
if (!resolvedSearchSpaceId) {
|
||||
toast.error("Cannot open chat outside a search space.");
|
||||
if (!resolvedWorkspaceId) {
|
||||
toast.error("Cannot open chat outside a workspace.");
|
||||
return;
|
||||
}
|
||||
router.push(`/dashboard/${resolvedSearchSpaceId}/new-chat/${threadId}`);
|
||||
router.push(`/dashboard/${resolvedWorkspaceId}/new-chat/${threadId}`);
|
||||
},
|
||||
[resolvedSearchSpaceId, router]
|
||||
[resolvedWorkspaceId, router]
|
||||
);
|
||||
|
||||
const segments = parseMentionSegments(text, mentionedDocs);
|
||||
|
|
@ -118,40 +114,37 @@ const UserTextPart: FC = () => {
|
|||
if (segment.type === "text") {
|
||||
return <span key={`txt-${segment.start}`}>{segment.value}</span>;
|
||||
}
|
||||
const isFolder = segment.doc.kind === "folder";
|
||||
const isConnector = segment.doc.kind === "connector";
|
||||
const isThread = segment.doc.kind === "thread";
|
||||
const icon = isFolder ? (
|
||||
<FolderIcon className="size-3.5" />
|
||||
) : isThread ? (
|
||||
<MessageSquare className="size-3.5" />
|
||||
) : isConnector ? (
|
||||
(getConnectorIcon(segment.doc.connector_type, "size-3.5") ?? (
|
||||
<Plug className="size-3.5" />
|
||||
))
|
||||
) : (
|
||||
getConnectorIcon(segment.doc.document_type ?? "UNKNOWN", "size-3.5")
|
||||
);
|
||||
const doc = segment.doc;
|
||||
const icon =
|
||||
doc.kind === "folder" ? (
|
||||
<FolderIcon className="size-3.5" />
|
||||
) : doc.kind === "thread" ? (
|
||||
<MessageSquare className="size-3.5" />
|
||||
) : doc.kind === "connector" ? (
|
||||
(getConnectorIcon(doc.connector_type, "size-3.5") ?? <Plug className="size-3.5" />)
|
||||
) : (
|
||||
getConnectorIcon(doc.document_type ?? "UNKNOWN", "size-3.5")
|
||||
);
|
||||
return (
|
||||
<MentionChip
|
||||
key={`mention-${getMentionDocKey(segment.doc)}-${segment.start}`}
|
||||
key={`mention-${getMentionDocKey(doc)}-${segment.start}`}
|
||||
icon={icon}
|
||||
label={segment.doc.title}
|
||||
label={doc.title}
|
||||
tooltip={
|
||||
isFolder
|
||||
? `Folder: ${segment.doc.title}`
|
||||
: isThread
|
||||
? `Chat: ${segment.doc.title}`
|
||||
: isConnector
|
||||
? `Connector account: ${segment.doc.title}`
|
||||
: segment.doc.title
|
||||
doc.kind === "folder"
|
||||
? `Folder: ${doc.title}`
|
||||
: doc.kind === "thread"
|
||||
? `Chat: ${doc.title}`
|
||||
: doc.kind === "connector"
|
||||
? `Connector account: ${doc.title}`
|
||||
: doc.title
|
||||
}
|
||||
onClick={
|
||||
isThread
|
||||
? () => handleOpenThread(segment.doc.id)
|
||||
: isFolder || isConnector
|
||||
doc.kind === "thread"
|
||||
? () => handleOpenThread(doc.id)
|
||||
: doc.kind === "folder" || doc.kind === "connector"
|
||||
? undefined
|
||||
: () => handleOpenDoc(segment.doc.id, segment.doc.title)
|
||||
: () => handleOpenDoc(doc.id, doc.title)
|
||||
}
|
||||
className="mx-0.5"
|
||||
/>
|
||||
|
|
@ -190,6 +183,7 @@ export const UserMessage: FC = () => {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
<MessageTimestamp className="mt-1 pl-1" />
|
||||
</div>
|
||||
</MessagePrimitive.Root>
|
||||
);
|
||||
|
|
|
|||
23
surfsense_web/components/chat/active-chat-stream-runner.tsx
Normal file
23
surfsense_web/components/chat/active-chat-stream-runner.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { chatStreamStore } from "@/lib/chat/stream-engine/store";
|
||||
|
||||
/**
|
||||
* Persistent, render-null host that scopes the in-flight chat turn's lifetime
|
||||
* to the workspace shell, not the chat page.
|
||||
*
|
||||
* Mounted in ``LayoutDataProvider``, it survives in-app navigation between
|
||||
* workspace routes and aborts the single active turn only on workspace/app
|
||||
* teardown, so ordinary navigation disconnects the view without stopping the
|
||||
* stream.
|
||||
*/
|
||||
export function ActiveChatStreamRunner() {
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
chatStreamStore.abortActive();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import { useEffect, useMemo, useRef } from "react";
|
|||
import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
|
||||
import { MarkdownViewer } from "@/components/markdown-viewer";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||
|
||||
|
|
@ -16,7 +17,6 @@ const DEFAULT_CHUNK_WINDOW = 5;
|
|||
interface CitationPanelContentProps {
|
||||
chunkId: number;
|
||||
onClose?: () => void;
|
||||
showHeader?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -25,11 +25,7 @@ interface CitationPanelContentProps {
|
|||
* with the cited one visually highlighted and auto-scrolled into view.
|
||||
* The user can jump to the full document via the editor panel.
|
||||
*/
|
||||
export const CitationPanelContent: FC<CitationPanelContentProps> = ({
|
||||
chunkId,
|
||||
onClose,
|
||||
showHeader = true,
|
||||
}) => {
|
||||
export const CitationPanelContent: FC<CitationPanelContentProps> = ({ chunkId, onClose }) => {
|
||||
const openEditorPanel = useSetAtom(openEditorPanelAtom);
|
||||
|
||||
const chunkWindow = DEFAULT_CHUNK_WINDOW;
|
||||
|
|
@ -77,7 +73,7 @@ export const CitationPanelContent: FC<CitationPanelContentProps> = ({
|
|||
if (!data) return;
|
||||
openEditorPanel({
|
||||
documentId: data.id,
|
||||
searchSpaceId: data.search_space_id,
|
||||
workspaceId: data.workspace_id,
|
||||
title: data.title,
|
||||
});
|
||||
};
|
||||
|
|
@ -85,32 +81,13 @@ export const CitationPanelContent: FC<CitationPanelContentProps> = ({
|
|||
return (
|
||||
<>
|
||||
<div className="shrink-0">
|
||||
{showHeader && (
|
||||
<div className="shrink-0 flex h-12 items-center justify-between px-3 border-b">
|
||||
<h2 className="select-none text-lg font-semibold">Citation</h2>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{onClose && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="h-8 w-8 rounded-full shrink-0 text-muted-foreground hover:text-accent-foreground"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
<span className="sr-only">Close citation panel</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid h-10 grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border-b px-4">
|
||||
<div className="grid h-12 grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border-b px-4">
|
||||
<div className="min-w-0 flex flex-1 items-center gap-2">
|
||||
<p className="truncate text-sm text-muted-foreground">
|
||||
{data?.title ?? (isLoading ? "Loading…" : `Chunk #${chunkId}`)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0 text-[11px] text-muted-foreground">
|
||||
{totalChunks > 0 && <span>{totalChunks} chunks</span>}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{!isLoading && !error && data && (
|
||||
<Button
|
||||
variant="default"
|
||||
|
|
@ -121,6 +98,23 @@ export const CitationPanelContent: FC<CitationPanelContentProps> = ({
|
|||
Open
|
||||
</Button>
|
||||
)}
|
||||
{onClose && (
|
||||
<>
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="mx-1.5 bg-muted-foreground/20 data-[orientation=vertical]:h-4 data-[orientation=vertical]:w-px dark:bg-muted-foreground/25"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="size-6 shrink-0 rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close citation panel</span>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import type { ReactNode } from "react";
|
||||
import { InlineCitation, UrlCitation } from "@/components/assistant-ui/inline-citation";
|
||||
import { RunCitation } from "@/components/citations/run-citation";
|
||||
import {
|
||||
type CitationToken,
|
||||
type CitationUrlMap,
|
||||
|
|
@ -21,6 +22,9 @@ export function renderCitationToken(token: CitationToken, ordinalKey: number): R
|
|||
if (token.kind === "url") {
|
||||
return <UrlCitation key={`citation-url-${ordinalKey}`} url={token.url} />;
|
||||
}
|
||||
if (token.kind === "run") {
|
||||
return <RunCitation key={`citation-run-${token.runId}-${ordinalKey}`} runId={token.runId} />;
|
||||
}
|
||||
return (
|
||||
<InlineCitation
|
||||
key={`citation-${token.isDocsChunk ? "doc-" : ""}${token.chunkId}-${ordinalKey}`}
|
||||
|
|
|
|||
47
surfsense_web/components/citations/run-citation-panel.tsx
Normal file
47
surfsense_web/components/citations/run-citation-panel.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"use client";
|
||||
|
||||
import { XIcon } from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import type { FC } from "react";
|
||||
import { RunDetail } from "@/app/dashboard/[workspace_id]/playground/components/run-detail";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
/** Right-panel viewer for a cited scraper run. `runId` is the `run_<uuid>` handle. */
|
||||
export const RunCitationPanelContent: FC<{ runId: string; onClose?: () => void }> = ({
|
||||
runId,
|
||||
onClose,
|
||||
}) => {
|
||||
const params = useParams();
|
||||
const rawWorkspaceId = Array.isArray(params?.workspace_id)
|
||||
? params.workspace_id[0]
|
||||
: params?.workspace_id;
|
||||
const workspaceId = Number(rawWorkspaceId);
|
||||
const scraperRunId = runId.replace(/^run_/, "");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="shrink-0 flex h-12 items-center justify-between px-3 border-b">
|
||||
<h2 className="select-none text-lg font-semibold">Scraper run</h2>
|
||||
{onClose && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="h-8 w-8 rounded-full shrink-0 text-muted-foreground hover:text-accent-foreground"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
<span className="sr-only">Close run panel</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{Number.isFinite(workspaceId) ? (
|
||||
<RunDetail workspaceId={workspaceId} runId={scraperRunId} />
|
||||
) : (
|
||||
<p className="p-4 text-sm text-muted-foreground">Open a workspace to view this run.</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
31
surfsense_web/components/citations/run-citation.tsx
Normal file
31
surfsense_web/components/citations/run-citation.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
"use client";
|
||||
|
||||
import { useSetAtom } from "jotai";
|
||||
import { Database } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { openRunCitationPanelAtom } from "@/atoms/citation/citation-panel.atom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
/** Inline citation badge for a scraper run; opens the run in the citation panel. */
|
||||
export const RunCitation: FC<{ runId: string }> = ({ runId }) => {
|
||||
const openRunPanel = useSetAtom(openRunCitationPanelAtom);
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => openRunPanel({ runId })}
|
||||
className="ml-0.5 inline-flex h-5 min-w-5 items-center justify-center gap-0.5 rounded-md bg-popover px-1.5 text-[11px] font-medium text-popover-foreground/80 align-baseline"
|
||||
aria-label="See where this came from"
|
||||
>
|
||||
<Database className="size-3" />
|
||||
Source
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>See where this came from</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
"use client";
|
||||
|
||||
import { Check } from "lucide-react";
|
||||
import { motion, useReducedMotion, type Variants } from "motion/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { AgentTranscript as AgentTranscriptModel } from "@/lib/connectors-marketing/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Custom ease-out: fast start, gentle settle. Never ease-in for entrances.
|
||||
const EASE_OUT: [number, number, number, number] = [0.16, 1, 0.3, 1];
|
||||
|
||||
const container: Variants = {
|
||||
hidden: {},
|
||||
show: { transition: { staggerChildren: 0.06, delayChildren: 0.08 } },
|
||||
};
|
||||
|
||||
// Enter from a small offset + fade. Never from scale(0). Under 300ms per item.
|
||||
const item: Variants = {
|
||||
hidden: { opacity: 0, y: 8 },
|
||||
show: { opacity: 1, y: 0, transition: { duration: 0.24, ease: EASE_OUT } },
|
||||
};
|
||||
|
||||
/** Reveal a string one character at a time. Returns the full string instantly when disabled. */
|
||||
function useTypedText(text: string, enabled: boolean, speedMs = 16) {
|
||||
const [count, setCount] = useState(enabled ? 0 : text.length);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setCount(text.length);
|
||||
return;
|
||||
}
|
||||
setCount(0);
|
||||
let i = 0;
|
||||
const id = window.setInterval(() => {
|
||||
i += 1;
|
||||
setCount(i);
|
||||
if (i >= text.length) window.clearInterval(id);
|
||||
}, speedMs);
|
||||
return () => window.clearInterval(id);
|
||||
}, [text, enabled, speedMs]);
|
||||
|
||||
return { shown: text.slice(0, count), done: count >= text.length };
|
||||
}
|
||||
|
||||
export function AgentTranscript({
|
||||
transcript,
|
||||
className,
|
||||
}: {
|
||||
transcript: AgentTranscriptModel;
|
||||
className?: string;
|
||||
}) {
|
||||
const reduce = useReducedMotion() ?? false;
|
||||
const animate = !reduce;
|
||||
const { shown: typedPrompt, done: promptDone } = useTypedText(transcript.prompt, animate);
|
||||
const revealed = promptDone;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full overflow-hidden rounded-xl border bg-card shadow-sm",
|
||||
"font-mono text-sm",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Window chrome */}
|
||||
<div className="flex items-center gap-2 border-b bg-muted/40 px-4 py-2.5">
|
||||
<span className="flex gap-1.5" aria-hidden>
|
||||
<span className="size-2.5 rounded-full bg-muted-foreground/25" />
|
||||
<span className="size-2.5 rounded-full bg-muted-foreground/25" />
|
||||
<span className="size-2.5 rounded-full bg-muted-foreground/25" />
|
||||
</span>
|
||||
<span className="ml-1 text-xs text-muted-foreground">agent · surfsense</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 p-4 sm:p-5">
|
||||
{/* Prompt line (typed) */}
|
||||
<p className="flex flex-wrap items-baseline gap-x-2 leading-relaxed">
|
||||
<span className="select-none text-muted-foreground">$</span>
|
||||
<span className="text-foreground">
|
||||
{typedPrompt}
|
||||
{animate && !promptDone && (
|
||||
<span className="ml-0.5 inline-block h-3.5 w-1.5 translate-y-0.5 animate-pulse bg-foreground/70" />
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
{/* Tool call + results reveal only after the prompt is typed */}
|
||||
<motion.div
|
||||
className="space-y-4"
|
||||
variants={container}
|
||||
initial={animate ? "hidden" : false}
|
||||
animate={revealed ? "show" : "hidden"}
|
||||
>
|
||||
<motion.pre
|
||||
variants={item}
|
||||
className="overflow-x-auto rounded-lg border bg-muted/50 px-3 py-2.5 text-xs leading-relaxed text-muted-foreground"
|
||||
>
|
||||
<code className="whitespace-pre-wrap wrap-break-word text-foreground/80">
|
||||
{transcript.toolCall}
|
||||
</code>
|
||||
</motion.pre>
|
||||
|
||||
<ul className="space-y-2">
|
||||
{transcript.rows.map((row) => (
|
||||
<motion.li
|
||||
key={row.primary}
|
||||
variants={item}
|
||||
className="flex items-start justify-between gap-3 rounded-lg border bg-background px-3 py-2.5"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-[13px] font-medium text-foreground">
|
||||
{row.primary}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-xs text-muted-foreground">
|
||||
{row.secondary}
|
||||
</span>
|
||||
</span>
|
||||
{row.tag && (
|
||||
<span className="shrink-0 rounded-full border border-brand/30 bg-brand/10 px-2 py-0.5 text-[10px] font-medium tracking-wide text-brand uppercase">
|
||||
{row.tag}
|
||||
</span>
|
||||
)}
|
||||
</motion.li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<motion.p
|
||||
variants={item}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground"
|
||||
>
|
||||
<Check className="size-3.5 text-brand" aria-hidden />
|
||||
{transcript.resultSummary}
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
255
surfsense_web/components/connectors-marketing/api-mcp-tabs.tsx
Normal file
255
surfsense_web/components/connectors-marketing/api-mcp-tabs.tsx
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
"use client";
|
||||
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import type { ApiSample } from "@/lib/connectors-marketing/types";
|
||||
|
||||
/** Pretty-print the request body as canonical JSON (a valid JS/JSON object literal). */
|
||||
function prettyJson(requestBody: Record<string, unknown>): string {
|
||||
return JSON.stringify(requestBody, null, 2);
|
||||
}
|
||||
|
||||
/** Indent every line except the first by `pad` (for splicing a JSON block inline). */
|
||||
function indentRest(text: string, pad: string): string {
|
||||
return text
|
||||
.split("\n")
|
||||
.map((line, i) => (i === 0 ? line : pad + line))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/** Render one request-body value as a Python literal (True/False, not true/false). */
|
||||
function toPythonValue(value: unknown): string {
|
||||
if (typeof value === "boolean") return value ? "True" : "False";
|
||||
if (Array.isArray(value)) return `[${value.map((v) => JSON.stringify(v)).join(", ")}]`;
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* `"key" <arrow> <json-value>,` lines for languages whose literal syntax matches
|
||||
* JSON scalar/array values (Ruby hashes, PHP arrays).
|
||||
*/
|
||||
function kvLines(requestBody: Record<string, unknown>, arrow: string, pad: string): string {
|
||||
return Object.entries(requestBody)
|
||||
.map(([k, v]) => `${pad}"${k}" ${arrow} ${JSON.stringify(v)},`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function buildCurl({ platform, verb, requestBody }: ApiSample): string {
|
||||
const body = indentRest(prettyJson(requestBody), " ");
|
||||
return [
|
||||
`curl -X POST "$SURFSENSE_API_URL/workspaces/$WORKSPACE_ID/scrapers/${platform}/${verb}" \\`,
|
||||
` -H "Authorization: Bearer $SURFSENSE_API_KEY" \\`,
|
||||
` -H "Content-Type: application/json" \\`,
|
||||
` -d '${body}'`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildPython({ platform, verb, requestBody }: ApiSample): string {
|
||||
const entries = Object.entries(requestBody)
|
||||
.map(([k, v]) => ` ${JSON.stringify(k)}: ${toPythonValue(v)},`)
|
||||
.join("\n");
|
||||
return [
|
||||
"import os",
|
||||
"import requests",
|
||||
"",
|
||||
"base = os.environ['SURFSENSE_API_URL']",
|
||||
"key = os.environ['SURFSENSE_API_KEY']",
|
||||
"",
|
||||
"resp = requests.post(",
|
||||
` f"{base}/workspaces/{WORKSPACE_ID}/scrapers/${platform}/${verb}",`,
|
||||
' headers={"Authorization": f"Bearer {key}"},',
|
||||
" json={",
|
||||
entries,
|
||||
" },",
|
||||
")",
|
||||
"resp.raise_for_status()",
|
||||
'items = resp.json()["items"]',
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildJavaScript({ platform, verb, requestBody }: ApiSample): string {
|
||||
return [
|
||||
`const payload = ${prettyJson(requestBody)};`,
|
||||
"",
|
||||
"const res = await fetch(",
|
||||
" `${process.env.SURFSENSE_API_URL}/workspaces/${WORKSPACE_ID}/scrapers/" +
|
||||
platform +
|
||||
"/" +
|
||||
verb +
|
||||
"`,",
|
||||
" {",
|
||||
' method: "POST",',
|
||||
" headers: {",
|
||||
" Authorization: `Bearer ${process.env.SURFSENSE_API_KEY}`,",
|
||||
' "Content-Type": "application/json",',
|
||||
" },",
|
||||
" body: JSON.stringify(payload),",
|
||||
" },",
|
||||
");",
|
||||
"const { items } = await res.json();",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildGo({ platform, verb, requestBody }: ApiSample): string {
|
||||
return [
|
||||
"package main",
|
||||
"",
|
||||
"import (",
|
||||
'\t"bytes"',
|
||||
'\t"net/http"',
|
||||
'\t"os"',
|
||||
")",
|
||||
"",
|
||||
"func main() {",
|
||||
"\tpayload := []byte(`" + prettyJson(requestBody) + "`)",
|
||||
'\turl := os.Getenv("SURFSENSE_API_URL") + "/workspaces/" +',
|
||||
'\t\tos.Getenv("WORKSPACE_ID") + "/scrapers/' + platform + "/" + verb + '"',
|
||||
'\treq, _ := http.NewRequest("POST", url, bytes.NewReader(payload))',
|
||||
'\treq.Header.Set("Authorization", "Bearer "+os.Getenv("SURFSENSE_API_KEY"))',
|
||||
'\treq.Header.Set("Content-Type", "application/json")',
|
||||
"\tresp, _ := http.DefaultClient.Do(req)",
|
||||
"\tdefer resp.Body.Close()",
|
||||
"}",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildPhp({ platform, verb, requestBody }: ApiSample): string {
|
||||
return [
|
||||
"<?php",
|
||||
"$ch = curl_init();",
|
||||
"curl_setopt_array($ch, [",
|
||||
' CURLOPT_URL => getenv("SURFSENSE_API_URL")',
|
||||
' . "/workspaces/" . getenv("WORKSPACE_ID")',
|
||||
' . "/scrapers/' + platform + "/" + verb + '",',
|
||||
" CURLOPT_POST => true,",
|
||||
" CURLOPT_RETURNTRANSFER => true,",
|
||||
" CURLOPT_HTTPHEADER => [",
|
||||
' "Authorization: Bearer " . getenv("SURFSENSE_API_KEY"),',
|
||||
' "Content-Type: application/json",',
|
||||
" ],",
|
||||
" CURLOPT_POSTFIELDS => json_encode([",
|
||||
kvLines(requestBody, "=>", " "),
|
||||
" ]),",
|
||||
"]);",
|
||||
'$items = json_decode(curl_exec($ch), true)["items"];',
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildRuby({ platform, verb, requestBody }: ApiSample): string {
|
||||
return [
|
||||
'require "net/http"',
|
||||
'require "json"',
|
||||
'require "uri"',
|
||||
"",
|
||||
"uri = URI(\"#{ENV['SURFSENSE_API_URL']}/workspaces/#{ENV['WORKSPACE_ID']}/scrapers/" +
|
||||
platform +
|
||||
"/" +
|
||||
verb +
|
||||
'")',
|
||||
"req = Net::HTTP::Post.new(uri)",
|
||||
'req["Authorization"] = "Bearer #{ENV[\'SURFSENSE_API_KEY\']}"',
|
||||
'req["Content-Type"] = "application/json"',
|
||||
"req.body = {",
|
||||
kvLines(requestBody, "=>", " "),
|
||||
"}.to_json",
|
||||
"",
|
||||
"res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }",
|
||||
'items = JSON.parse(res.body)["items"]',
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildJava({ platform, verb, requestBody }: ApiSample): string {
|
||||
return [
|
||||
"import java.net.URI;",
|
||||
"import java.net.http.HttpClient;",
|
||||
"import java.net.http.HttpRequest;",
|
||||
"import java.net.http.HttpResponse;",
|
||||
"",
|
||||
"var client = HttpClient.newHttpClient();",
|
||||
'String payload = """',
|
||||
prettyJson(requestBody),
|
||||
'""";',
|
||||
"var request = HttpRequest.newBuilder()",
|
||||
' .uri(URI.create(System.getenv("SURFSENSE_API_URL")',
|
||||
' + "/workspaces/" + System.getenv("WORKSPACE_ID")',
|
||||
' + "/scrapers/' + platform + "/" + verb + '"))',
|
||||
' .header("Authorization", "Bearer " + System.getenv("SURFSENSE_API_KEY"))',
|
||||
' .header("Content-Type", "application/json")',
|
||||
" .POST(HttpRequest.BodyPublishers.ofString(payload))",
|
||||
" .build();",
|
||||
"var response = client.send(request, HttpResponse.BodyHandlers.ofString());",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildCsharp({ platform, verb, requestBody }: ApiSample): string {
|
||||
return [
|
||||
"using System;",
|
||||
"using System.Net.Http;",
|
||||
"using System.Text;",
|
||||
"",
|
||||
"var http = new HttpClient();",
|
||||
'var url = Environment.GetEnvironmentVariable("SURFSENSE_API_URL")',
|
||||
' + "/workspaces/" + Environment.GetEnvironmentVariable("WORKSPACE_ID")',
|
||||
' + "/scrapers/' + platform + "/" + verb + '";',
|
||||
'var payload = """',
|
||||
prettyJson(requestBody),
|
||||
'""";',
|
||||
"var request = new HttpRequestMessage(HttpMethod.Post, url)",
|
||||
"{",
|
||||
' Content = new StringContent(payload, Encoding.UTF8, "application/json"),',
|
||||
"};",
|
||||
'request.Headers.Add("Authorization", "Bearer " + Environment.GetEnvironmentVariable("SURFSENSE_API_KEY"));',
|
||||
"var response = await http.SendAsync(request);",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildMcp({ mcpTool }: ApiSample): string {
|
||||
const config = {
|
||||
mcpServers: {
|
||||
surfsense: {
|
||||
url: "https://mcp.surfsense.com/mcp",
|
||||
headers: { Authorization: "Bearer ${SURFSENSE_API_KEY}" },
|
||||
},
|
||||
},
|
||||
};
|
||||
return `${JSON.stringify(config, null, 2)}\n\n# Your agent can now call the ${mcpTool} tool.`;
|
||||
}
|
||||
|
||||
/** Ordered language set for the code-sample tabs. cURL is the default. */
|
||||
const SAMPLES: { value: string; label: string; build: (api: ApiSample) => string }[] = [
|
||||
{ value: "curl", label: "cURL", build: buildCurl },
|
||||
{ value: "python", label: "Python", build: buildPython },
|
||||
{ value: "javascript", label: "JavaScript", build: buildJavaScript },
|
||||
{ value: "go", label: "Go", build: buildGo },
|
||||
{ value: "php", label: "PHP", build: buildPhp },
|
||||
{ value: "ruby", label: "Ruby", build: buildRuby },
|
||||
{ value: "java", label: "Java", build: buildJava },
|
||||
{ value: "csharp", label: "C#", build: buildCsharp },
|
||||
{ value: "mcp", label: "MCP", build: buildMcp },
|
||||
];
|
||||
|
||||
function CodeBlock({ code }: { code: string }) {
|
||||
return (
|
||||
<pre className="overflow-x-auto rounded-lg border bg-muted/50 p-4 text-xs leading-relaxed">
|
||||
<code className="font-mono text-foreground/90">{code}</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
export function ApiMcpTabs({ api }: { api: ApiSample }) {
|
||||
return (
|
||||
<Tabs defaultValue="curl" className="w-full">
|
||||
<TabsList className="flex h-auto flex-wrap justify-start gap-1">
|
||||
{SAMPLES.map((sample) => (
|
||||
<TabsTrigger key={sample.value} value={sample.value}>
|
||||
{sample.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{SAMPLES.map((sample) => (
|
||||
<TabsContent key={sample.value} value={sample.value}>
|
||||
<CodeBlock code={sample.build(api)} />
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import type { FaqItem } from "@/lib/connectors-marketing/types";
|
||||
|
||||
export function ConnectorFaq({ items }: { items: FaqItem[] }) {
|
||||
return (
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
{items.map((item, i) => (
|
||||
<AccordionItem key={item.question} value={`item-${i}`}>
|
||||
<AccordionTrigger className="text-base">{item.question}</AccordionTrigger>
|
||||
<AccordionContent className="text-muted-foreground leading-relaxed">
|
||||
{item.answer}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
310
surfsense_web/components/connectors-marketing/connector-page.tsx
Normal file
310
surfsense_web/components/connectors-marketing/connector-page.tsx
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
import { IconBrandGithub } from "@tabler/icons-react";
|
||||
import { ArrowRight, Check } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { MarketingSection } from "@/components/marketing/section";
|
||||
import { BreadcrumbNav } from "@/components/seo/breadcrumb-nav";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import type { ConnectorPageContent, SchemaField } from "@/lib/connectors-marketing/types";
|
||||
import { AgentTranscript } from "./agent-transcript";
|
||||
import { ApiMcpTabs } from "./api-mcp-tabs";
|
||||
import { ConnectorFaq } from "./connector-faq";
|
||||
import { Reveal } from "./reveal";
|
||||
|
||||
const GITHUB_URL = "https://github.com/MODSetter/SurfSense";
|
||||
|
||||
function SchemaTable({ caption, fields }: { caption: string; fields: SchemaField[] }) {
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-xl border bg-card">
|
||||
<table className="w-full min-w-xl text-sm">
|
||||
<caption className="sr-only">{caption}</caption>
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/40 text-left">
|
||||
<th className="p-4 font-medium">Field</th>
|
||||
<th className="p-4 font-medium">Type</th>
|
||||
<th className="p-4 font-medium">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{fields.map((field) => (
|
||||
<tr key={field.name} className="border-b align-top last:border-b-0">
|
||||
<th scope="row" className="p-4 text-left">
|
||||
<code className="font-mono text-[13px] font-semibold">{field.name}</code>
|
||||
</th>
|
||||
<td className="whitespace-nowrap p-4">
|
||||
<code className="font-mono text-[13px] text-muted-foreground">{field.type}</code>
|
||||
{field.required ? (
|
||||
<span className="ml-2 rounded-full bg-brand/10 px-2 py-0.5 text-xs font-medium text-brand">
|
||||
required
|
||||
</span>
|
||||
) : null}
|
||||
{field.defaultValue !== undefined ? (
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
default <code className="font-mono">{field.defaultValue}</code>
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="p-4 text-muted-foreground leading-relaxed">{field.description}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConnectorPage({ content }: { content: ConnectorPageContent }) {
|
||||
const Icon = content.icon;
|
||||
const label = content.cardTitle ?? `${content.name} API`;
|
||||
|
||||
return (
|
||||
<div className="pb-4">
|
||||
{/* Hero */}
|
||||
<MarketingSection className="pt-28 pb-12 sm:pt-32 sm:pb-16">
|
||||
<div className="grid items-center gap-10 lg:grid-cols-2 lg:gap-14">
|
||||
<div>
|
||||
<BreadcrumbNav
|
||||
className="mb-6"
|
||||
items={[
|
||||
{ name: "Connectors", href: "/connectors" },
|
||||
{ name: content.name, href: `/${content.slug}` },
|
||||
]}
|
||||
/>
|
||||
<Badge variant="outline" className="mb-5 gap-1.5 py-1">
|
||||
<Icon className="size-3.5" />
|
||||
{content.name} connector
|
||||
</Badge>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-balance sm:text-4xl lg:text-5xl">
|
||||
{content.h1}
|
||||
</h1>
|
||||
<p className="mt-5 max-w-xl text-base leading-relaxed text-muted-foreground sm:text-lg">
|
||||
{content.heroLede}
|
||||
</p>
|
||||
<div className="mt-8 flex flex-wrap items-center gap-3">
|
||||
<Button asChild size="lg">
|
||||
<Link href="/register">
|
||||
Start for free
|
||||
<ArrowRight className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="lg">
|
||||
<Link href="/docs">Read the docs</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="lg">
|
||||
<Link href={GITHUB_URL} target="_blank" rel="noopener noreferrer">
|
||||
<IconBrandGithub className="size-4" />
|
||||
GitHub
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<AgentTranscript transcript={content.transcript} />
|
||||
</div>
|
||||
</MarketingSection>
|
||||
|
||||
{/* What you can extract */}
|
||||
<MarketingSection>
|
||||
<Reveal>
|
||||
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
What you can extract from {content.name}
|
||||
</h2>
|
||||
<p className="mt-3 max-w-2xl text-muted-foreground leading-relaxed">
|
||||
{content.extractIntro}
|
||||
</p>
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
<div className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{content.extractFields.map((field) => (
|
||||
<div
|
||||
key={field.label}
|
||||
className="rounded-xl border bg-card p-5 transition-colors hover:border-brand/40"
|
||||
>
|
||||
<h3 className="flex items-center gap-2 font-semibold">
|
||||
<Check className="size-4 text-brand" aria-hidden />
|
||||
{field.label}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
|
||||
{field.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Reveal>
|
||||
</MarketingSection>
|
||||
|
||||
{/* Use cases */}
|
||||
<MarketingSection>
|
||||
<Reveal>
|
||||
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
{content.useCasesHeading}
|
||||
</h2>
|
||||
</Reveal>
|
||||
<div className="mt-8 grid gap-6 sm:grid-cols-2">
|
||||
{content.useCases.map((useCase) => (
|
||||
<Reveal key={useCase.title}>
|
||||
<div className="h-full rounded-xl border bg-card p-6">
|
||||
<h3 className="text-lg font-semibold">{useCase.title}</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
|
||||
{useCase.description}
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</MarketingSection>
|
||||
|
||||
{/* API / MCP */}
|
||||
<MarketingSection>
|
||||
<Reveal>
|
||||
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
Call it from your code or your agent
|
||||
</h2>
|
||||
<p className="mt-3 max-w-2xl text-muted-foreground leading-relaxed">
|
||||
One typed endpoint, one API key. Or add the SurfSense MCP server and let your agent call{" "}
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-sm">
|
||||
{content.api.mcpTool}
|
||||
</code>{" "}
|
||||
as a native tool.
|
||||
</p>
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
{/* Code capped at a readable measure; left edge stays on the page grid. */}
|
||||
<div className="mt-8 max-w-4xl">
|
||||
<ApiMcpTabs api={content.api} />
|
||||
</div>
|
||||
</Reveal>
|
||||
</MarketingSection>
|
||||
|
||||
{/* Request / response schema */}
|
||||
<MarketingSection>
|
||||
<Reveal>
|
||||
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
{content.name} API request and response schema
|
||||
</h2>
|
||||
<p className="mt-3 max-w-2xl text-muted-foreground leading-relaxed">
|
||||
The exact contract behind{" "}
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-sm">
|
||||
POST /workspaces/{"{workspace_id}"}/scrapers/{content.api.platform}/{content.api.verb}
|
||||
</code>
|
||||
. The same fields power the{" "}
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-sm">
|
||||
{content.api.mcpTool}
|
||||
</code>{" "}
|
||||
MCP tool.
|
||||
</p>
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
<h3 className="mt-8 text-lg font-semibold">Request parameters</h3>
|
||||
<p className="mt-2 max-w-2xl text-sm text-muted-foreground leading-relaxed">
|
||||
{content.schema.requestNote}
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<SchemaTable caption="Request parameters" fields={content.schema.request} />
|
||||
</div>
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
<h3 className="mt-10 text-lg font-semibold">Response fields</h3>
|
||||
<p className="mt-2 max-w-2xl text-sm text-muted-foreground leading-relaxed">
|
||||
{content.schema.responseNote}
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<SchemaTable caption="Response fields" fields={content.schema.response} />
|
||||
</div>
|
||||
</Reveal>
|
||||
</MarketingSection>
|
||||
|
||||
{/* Comparison */}
|
||||
<MarketingSection>
|
||||
<Reveal>
|
||||
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
{content.comparison.heading}
|
||||
</h2>
|
||||
<p className="mt-3 max-w-2xl text-muted-foreground leading-relaxed">
|
||||
{content.comparison.intro}
|
||||
</p>
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
<div className="mt-8 overflow-x-auto rounded-xl border bg-card">
|
||||
<table className="w-full min-w-xl text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/40 text-left">
|
||||
<th className="p-4 font-medium">Feature</th>
|
||||
<th className="p-4 font-medium text-muted-foreground">
|
||||
{content.comparison.columnLabel}
|
||||
</th>
|
||||
<th className="p-4 font-medium text-brand">SurfSense</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{content.comparison.rows.map((row) => (
|
||||
<tr key={row.feature} className="border-b last:border-b-0">
|
||||
<th scope="row" className="p-4 text-left font-medium">
|
||||
{row.feature}
|
||||
</th>
|
||||
<td className="p-4 text-muted-foreground">{row.official}</td>
|
||||
<td className="bg-brand/5 p-4 text-foreground">{row.surfsense}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Reveal>
|
||||
</MarketingSection>
|
||||
|
||||
{/* FAQ */}
|
||||
<MarketingSection>
|
||||
<Reveal>
|
||||
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
{label}: frequently asked questions
|
||||
</h2>
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
{/* Accordion capped at a readable measure; left edge stays on the page grid. */}
|
||||
<div className="mt-6 max-w-3xl">
|
||||
<ConnectorFaq items={content.faq} />
|
||||
</div>
|
||||
</Reveal>
|
||||
</MarketingSection>
|
||||
|
||||
{/* Closing CTA + related */}
|
||||
<MarketingSection>
|
||||
<Reveal>
|
||||
<div className="rounded-2xl border bg-card p-8 text-center sm:p-12">
|
||||
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
Point your agents at {content.name}
|
||||
</h2>
|
||||
<p className="mx-auto mt-3 max-w-xl text-muted-foreground leading-relaxed">
|
||||
The {content.name} connector is one of many in the SurfSense{" "}
|
||||
<Link href="/" className="font-medium text-foreground underline underline-offset-4">
|
||||
open web research platform
|
||||
</Link>
|
||||
. Start free, no credit card required.
|
||||
</p>
|
||||
<div className="mt-7 flex flex-wrap justify-center gap-3">
|
||||
<Button asChild size="lg">
|
||||
<Link href="/register">
|
||||
Start for free
|
||||
<ArrowRight className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="lg">
|
||||
<Link href="/pricing">See pricing</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator className="my-8" />
|
||||
|
||||
<nav aria-label="Other connectors" className="flex flex-wrap justify-center gap-2">
|
||||
{content.related.map((link) => (
|
||||
<Button key={link.href} asChild variant="ghost" size="sm">
|
||||
<Link href={link.href}>{link.label}</Link>
|
||||
</Button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</Reveal>
|
||||
</MarketingSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
surfsense_web/components/connectors-marketing/reveal.tsx
Normal file
38
surfsense_web/components/connectors-marketing/reveal.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
"use client";
|
||||
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const EASE_OUT: [number, number, number, number] = [0.16, 1, 0.3, 1];
|
||||
|
||||
/**
|
||||
* One quiet scroll reveal per section: fade + small rise, triggered once when
|
||||
* the block enters the viewport. Renders statically under prefers-reduced-motion.
|
||||
*/
|
||||
export function Reveal({
|
||||
children,
|
||||
className,
|
||||
delay = 0,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
delay?: number;
|
||||
}) {
|
||||
const reduce = useReducedMotion() ?? false;
|
||||
|
||||
if (reduce) {
|
||||
return <div className={className}>{children}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={className}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.28, ease: EASE_OUT, delay }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,11 +4,9 @@ import {
|
|||
AlertCircle,
|
||||
Clock,
|
||||
Download,
|
||||
Eye,
|
||||
History,
|
||||
MoreHorizontal,
|
||||
Move,
|
||||
Pencil,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
|
|
@ -39,12 +37,12 @@ import {
|
|||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SidebarListItem } from "../layout/ui/sidebar/SidebarListItem";
|
||||
import { DND_TYPES } from "./FolderNode";
|
||||
import { isVersionableType } from "./version-history";
|
||||
|
||||
const EDITABLE_DOCUMENT_TYPES = new Set(["FILE", "NOTE"]);
|
||||
|
||||
export interface DocumentNodeDoc {
|
||||
id: number;
|
||||
title: string;
|
||||
|
|
@ -59,7 +57,6 @@ interface DocumentNodeProps {
|
|||
isMentioned: boolean;
|
||||
onToggleChatMention: (doc: DocumentNodeDoc, isMentioned: boolean) => void;
|
||||
onPreview: (doc: DocumentNodeDoc) => void;
|
||||
onEdit: (doc: DocumentNodeDoc) => void;
|
||||
onDelete: (doc: DocumentNodeDoc) => void;
|
||||
onMove: (doc: DocumentNodeDoc) => void;
|
||||
onReset?: (doc: DocumentNodeDoc) => void;
|
||||
|
|
@ -68,7 +65,6 @@ interface DocumentNodeProps {
|
|||
canDelete?: boolean;
|
||||
canMove?: boolean;
|
||||
canMention?: boolean;
|
||||
canEdit?: boolean;
|
||||
contextMenuOpen?: boolean;
|
||||
onContextMenuOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
|
@ -79,7 +75,6 @@ export const DocumentNode = React.memo(function DocumentNode({
|
|||
isMentioned,
|
||||
onToggleChatMention,
|
||||
onPreview,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onMove,
|
||||
onReset,
|
||||
|
|
@ -88,7 +83,6 @@ export const DocumentNode = React.memo(function DocumentNode({
|
|||
canDelete = true,
|
||||
canMove = true,
|
||||
canMention = true,
|
||||
canEdit = true,
|
||||
contextMenuOpen,
|
||||
onContextMenuOpenChange,
|
||||
}: DocumentNodeProps) {
|
||||
|
|
@ -99,10 +93,7 @@ export const DocumentNode = React.memo(function DocumentNode({
|
|||
const isMemoryDocument =
|
||||
doc.document_type === "USER_MEMORY" || doc.document_type === "TEAM_MEMORY";
|
||||
const isSelectable = canMention && !isUnavailable;
|
||||
const isEditable =
|
||||
canEdit &&
|
||||
(isMemoryDocument || EDITABLE_DOCUMENT_TYPES.has(doc.document_type)) &&
|
||||
!isUnavailable;
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const handleCheckChange = useCallback(() => {
|
||||
if (isSelectable) {
|
||||
|
|
@ -111,12 +102,20 @@ export const DocumentNode = React.memo(function DocumentNode({
|
|||
}, [doc, isMentioned, isSelectable, onToggleChatMention]);
|
||||
|
||||
const handlePrimaryClick = useCallback(() => {
|
||||
if (canMention) {
|
||||
handleCheckChange();
|
||||
return;
|
||||
}
|
||||
if (isUnavailable) return;
|
||||
onPreview(doc);
|
||||
}, [canMention, doc, handleCheckChange, onPreview]);
|
||||
}, [doc, isUnavailable, onPreview]);
|
||||
|
||||
const handlePrimaryKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.currentTarget !== event.target) return;
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
|
||||
event.preventDefault();
|
||||
handlePrimaryClick();
|
||||
},
|
||||
[handlePrimaryClick]
|
||||
);
|
||||
|
||||
const [{ isDragging }, drag] = useDrag(
|
||||
() => ({
|
||||
|
|
@ -165,14 +164,17 @@ export const DocumentNode = React.memo(function DocumentNode({
|
|||
return (
|
||||
<ContextMenu onOpenChange={onContextMenuOpenChange}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
<SidebarListItem
|
||||
ref={attachRef}
|
||||
className={cn(
|
||||
"group flex h-8 w-full items-center gap-2.5 rounded-md px-1 text-sm hover:bg-accent hover:text-accent-foreground cursor-pointer select-none text-left",
|
||||
isMentioned && "bg-accent text-accent-foreground",
|
||||
isDragging && "opacity-40"
|
||||
)}
|
||||
active={isMentioned || dropdownOpen}
|
||||
dragging={isDragging}
|
||||
className="group/item relative gap-2.5 px-1"
|
||||
style={{ paddingLeft: `${depth * 16 + 4}px` }}
|
||||
role="button"
|
||||
tabIndex={isUnavailable ? -1 : 0}
|
||||
aria-disabled={isUnavailable}
|
||||
onClick={handlePrimaryClick}
|
||||
onKeyDown={handlePrimaryKeyDown}
|
||||
>
|
||||
{(() => {
|
||||
if (statusState === "pending") {
|
||||
|
|
@ -214,32 +216,34 @@ export const DocumentNode = React.memo(function DocumentNode({
|
|||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{isMemoryDocument ? (
|
||||
<span aria-disabled="true" className="h-3.5 w-3.5 shrink-0 cursor-default">
|
||||
<Checkbox
|
||||
checked={false}
|
||||
disabled
|
||||
aria-disabled
|
||||
className="h-3.5 w-3.5 pointer-events-none"
|
||||
/>
|
||||
</span>
|
||||
) : canMention ? (
|
||||
<span className="relative flex h-3.5 w-3.5 shrink-0 items-center justify-center">
|
||||
<span
|
||||
className={cn(
|
||||
"absolute inset-0 flex items-center justify-center transition-opacity",
|
||||
canMention &&
|
||||
(isMentioned ? "opacity-0" : "max-sm:opacity-0 group-hover/item:opacity-0")
|
||||
)}
|
||||
>
|
||||
{getDocumentTypeIcon(
|
||||
doc.document_type as DocumentTypeEnum,
|
||||
"h-3.5 w-3.5 text-muted-foreground"
|
||||
)}
|
||||
</span>
|
||||
{canMention ? (
|
||||
<Checkbox
|
||||
checked={isMentioned}
|
||||
aria-label={`Select ${doc.title}`}
|
||||
onCheckedChange={handleCheckChange}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-3.5 w-3.5 shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center">
|
||||
{getDocumentTypeIcon(
|
||||
doc.document_type as DocumentTypeEnum,
|
||||
"h-3.5 w-3.5 text-muted-foreground"
|
||||
className={cn(
|
||||
"absolute h-3.5 w-3.5 transition-opacity max-sm:pointer-events-auto max-sm:opacity-100",
|
||||
isMentioned
|
||||
? "opacity-100"
|
||||
: "pointer-events-none opacity-0 group-hover/item:pointer-events-auto group-hover/item:opacity-100 focus-visible:pointer-events-auto focus-visible:opacity-100"
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
|
||||
|
|
@ -249,53 +253,41 @@ export const DocumentNode = React.memo(function DocumentNode({
|
|||
onOpenChange={handleTitleTooltipOpenChange}
|
||||
>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
aria-disabled={canMention ? !isSelectable : false}
|
||||
onClick={handlePrimaryClick}
|
||||
className="h-full min-w-0 flex-1 justify-start bg-transparent px-0 py-0 text-left font-normal text-inherit hover:bg-transparent hover:text-inherit"
|
||||
>
|
||||
<span className="flex h-full min-w-0 flex-1 items-center text-left font-normal text-inherit">
|
||||
<span ref={titleRef} className="min-w-0 flex-1 truncate">
|
||||
{doc.title}
|
||||
</span>
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-xs break-words">
|
||||
{doc.title}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<span className="relative shrink-0 flex items-center justify-center h-6 w-6">
|
||||
{getDocumentTypeIcon(
|
||||
doc.document_type as DocumentTypeEnum,
|
||||
"h-3.5 w-3.5 text-muted-foreground"
|
||||
) && (
|
||||
<span
|
||||
className={cn(
|
||||
"absolute inset-0 flex items-center justify-center transition-opacity pointer-events-none",
|
||||
dropdownOpen ? "opacity-0" : "group-hover:opacity-0"
|
||||
)}
|
||||
>
|
||||
{getDocumentTypeIcon(
|
||||
doc.document_type as DocumentTypeEnum,
|
||||
"h-3.5 w-3.5 text-muted-foreground"
|
||||
)}
|
||||
</span>
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute top-0 right-0 bottom-0 flex items-center rounded-r-md pr-1 pl-6",
|
||||
isMentioned || dropdownOpen
|
||||
? "bg-gradient-to-l from-accent from-60% to-transparent"
|
||||
: "bg-gradient-to-l from-sidebar from-60% to-transparent group-hover/item:from-accent",
|
||||
isMobile
|
||||
? "opacity-0"
|
||||
: isMentioned || dropdownOpen
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover/item:opacity-100"
|
||||
)}
|
||||
|
||||
>
|
||||
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"hidden sm:inline-flex h-6 w-6 shrink-0 hover:bg-transparent",
|
||||
dropdownOpen
|
||||
? "opacity-100 bg-accent hover:bg-accent"
|
||||
: "opacity-0 group-hover:opacity-100"
|
||||
"pointer-events-auto hidden h-6 w-6 shrink-0 hover:bg-transparent sm:inline-flex",
|
||||
dropdownOpen && "bg-accent hover:bg-accent"
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={`Document actions for ${doc.title}`}
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
|
|
@ -305,16 +297,6 @@ export const DocumentNode = React.memo(function DocumentNode({
|
|||
className="w-40"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DropdownMenuItem onClick={() => onPreview(doc)} disabled={isUnavailable}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
Open
|
||||
</DropdownMenuItem>
|
||||
{isEditable && (
|
||||
<DropdownMenuItem onClick={() => onEdit(doc)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canMove && (
|
||||
<DropdownMenuItem onClick={() => onMove(doc)}>
|
||||
<Move className="mr-2 h-4 w-4" />
|
||||
|
|
@ -357,22 +339,12 @@ export const DocumentNode = React.memo(function DocumentNode({
|
|||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarListItem>
|
||||
</ContextMenuTrigger>
|
||||
|
||||
{contextMenuOpen && (
|
||||
<ContextMenuContent className="w-40" onClick={(e) => e.stopPropagation()}>
|
||||
<ContextMenuItem onClick={() => onPreview(doc)} disabled={isUnavailable}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
Open
|
||||
</ContextMenuItem>
|
||||
{isEditable && (
|
||||
<ContextMenuItem onClick={() => onEdit(doc)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{canMove && (
|
||||
<ContextMenuItem onClick={() => onMove(doc)}>
|
||||
<Move className="mr-2 h-4 w-4" />
|
||||
|
|
|
|||
|
|
@ -1,20 +1,16 @@
|
|||
"use client";
|
||||
|
||||
import { IconBinaryTree, IconBinaryTreeFilled } from "@tabler/icons-react";
|
||||
import { FolderPlus, ListFilter, Search, Upload, X } from "lucide-react";
|
||||
import { FolderPlus, ListFilter, Search, X } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
|
||||
import { getDocumentTypeLabel } from "@/lib/documents/document-type-labels";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getDocumentTypeIcon } from "./DocumentTypeIcon";
|
||||
|
||||
export function DocumentsFilters({
|
||||
|
|
@ -24,10 +20,6 @@ export function DocumentsFilters({
|
|||
onToggleType,
|
||||
activeTypes,
|
||||
onCreateFolder,
|
||||
aiSortEnabled = false,
|
||||
aiSortBusy = false,
|
||||
onToggleAiSort,
|
||||
onUploadClick,
|
||||
}: {
|
||||
typeCounts: Partial<Record<DocumentTypeEnum, number>>;
|
||||
onSearch: (v: string) => void;
|
||||
|
|
@ -35,18 +27,11 @@ export function DocumentsFilters({
|
|||
onToggleType: (type: DocumentTypeEnum, checked: boolean) => void;
|
||||
activeTypes: DocumentTypeEnum[];
|
||||
onCreateFolder?: () => void;
|
||||
aiSortEnabled?: boolean;
|
||||
aiSortBusy?: boolean;
|
||||
onToggleAiSort?: () => void;
|
||||
onUploadClick?: () => void;
|
||||
}) {
|
||||
const t = useTranslations("documents");
|
||||
const id = React.useId();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { openDialog: openUploadDialog } = useDocumentUploadDialog();
|
||||
const handleUpload = onUploadClick ?? openUploadDialog;
|
||||
|
||||
const [typeSearchQuery, setTypeSearchQuery] = useState("");
|
||||
const [scrollPos, setScrollPos] = useState<"top" | "middle" | "bottom">("top");
|
||||
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||
|
|
@ -77,7 +62,7 @@ export function DocumentsFilters({
|
|||
return (
|
||||
<div className="flex select-none">
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
{/* New Folder + AI Sort + Filter Toggle Group */}
|
||||
{/* New Folder + Filter Toggle Group */}
|
||||
<ToggleGroup type="multiple" value={[]} className="overflow-visible">
|
||||
{onCreateFolder && (
|
||||
<Tooltip>
|
||||
|
|
@ -97,46 +82,6 @@ export function DocumentsFilters({
|
|||
</Tooltip>
|
||||
)}
|
||||
|
||||
{onToggleAiSort && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<ToggleGroupItem
|
||||
value="ai-sort"
|
||||
disabled={aiSortBusy}
|
||||
className={cn(
|
||||
"h-8 w-8 shrink-0 border-0 bg-muted transition-colors",
|
||||
"relative before:absolute before:left-0 before:top-1/2 before:h-4 before:w-px before:-translate-y-1/2 before:bg-border/60 before:content-[''] dark:before:bg-white/10",
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
aiSortEnabled
|
||||
? "bg-accent text-accent-foreground hover:bg-accent"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onToggleAiSort();
|
||||
}}
|
||||
aria-label={aiSortEnabled ? "Disable AI sort" : "Enable AI sort"}
|
||||
aria-pressed={aiSortEnabled}
|
||||
>
|
||||
{aiSortBusy ? (
|
||||
<Spinner size="xs" />
|
||||
) : aiSortEnabled ? (
|
||||
<IconBinaryTreeFilled size={14} />
|
||||
) : (
|
||||
<IconBinaryTree size={14} />
|
||||
)}
|
||||
</ToggleGroupItem>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{aiSortBusy
|
||||
? "AI sort in progress..."
|
||||
: aiSortEnabled
|
||||
? "AI sort active — click to disable"
|
||||
: "Enable AI sort"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Popover>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -256,18 +201,6 @@ export function DocumentsFilters({
|
|||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Upload Button */}
|
||||
<Button
|
||||
data-joyride="upload-button"
|
||||
onClick={handleUpload}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 shrink-0 gap-1.5 border-0 bg-white text-gray-700 shadow-none hover:bg-accent hover:text-accent-foreground dark:bg-white dark:text-gray-800"
|
||||
>
|
||||
<Upload size={13} />
|
||||
<span>Upload</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import {
|
|||
ChevronDown,
|
||||
ChevronRight,
|
||||
Download,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
|
|
@ -18,6 +17,7 @@ import {
|
|||
} from "lucide-react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useDrag, useDrop } from "react-dnd";
|
||||
import { SidebarListItem } from "@/components/layout/ui/sidebar/SidebarListItem";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
|
|
@ -34,6 +34,7 @@ import {
|
|||
} from "@/components/ui/dropdown-menu";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { FolderSelectionState } from "./FolderTreeView";
|
||||
|
||||
|
|
@ -49,7 +50,7 @@ export interface FolderDisplay {
|
|||
name: string;
|
||||
position: string;
|
||||
parentId: number | null;
|
||||
searchSpaceId: number;
|
||||
workspaceId: number;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
|
|
@ -124,11 +125,13 @@ export const FolderNode = React.memo(function FolderNode({
|
|||
onStopWatching,
|
||||
onExportFolder,
|
||||
}: FolderNodeProps) {
|
||||
const isMobile = useIsMobile();
|
||||
const [renameValue, setRenameValue] = useState(folder.name);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const rowRef = useRef<HTMLDivElement>(null);
|
||||
const [dropZone, setDropZone] = useState<DropZone | null>(null);
|
||||
const [isRescanning, setIsRescanning] = useState(false);
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
|
||||
const handleRescan = useCallback(async () => {
|
||||
if (isRescanning) return;
|
||||
|
|
@ -254,15 +257,15 @@ export const FolderNode = React.memo(function FolderNode({
|
|||
return (
|
||||
<ContextMenu onOpenChange={onContextMenuOpenChange}>
|
||||
<ContextMenuTrigger asChild disabled={isRenaming}>
|
||||
{/* biome-ignore lint/a11y/useSemanticElements: div required for drag/drop refs */}
|
||||
<div
|
||||
<SidebarListItem
|
||||
ref={attachRef}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
dragging={isDragging}
|
||||
className={cn(
|
||||
"group relative flex h-8 items-center gap-1 rounded-md px-1 text-sm hover:bg-accent hover:text-accent-foreground cursor-pointer select-none",
|
||||
"group/item relative gap-1 px-1",
|
||||
isExpanded && "font-medium",
|
||||
isDragging && "opacity-40",
|
||||
dropdownOpen && "bg-accent text-accent-foreground",
|
||||
isOver && canDrop && dropZone === "middle" && "bg-accent ring-1 ring-primary/40",
|
||||
isOver && canDrop && dropZone === "top" && "border-t-2 border-primary",
|
||||
isOver && canDrop && dropZone === "bottom" && "border-b-2 border-primary",
|
||||
|
|
@ -291,11 +294,11 @@ export const FolderNode = React.memo(function FolderNode({
|
|||
)}
|
||||
</span>
|
||||
|
||||
{processingState !== "idle" && selectionState === "none" ? (
|
||||
<>
|
||||
<span className="relative flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
{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">
|
||||
<span className="flex h-3.5 w-3.5 items-center justify-center">
|
||||
{processingState === "processing" ? (
|
||||
<Spinner size="xs" className="text-primary" />
|
||||
) : (
|
||||
|
|
@ -309,29 +312,37 @@ export const FolderNode = React.memo(function FolderNode({
|
|||
: "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" />
|
||||
) : (
|
||||
<>
|
||||
<FolderIcon
|
||||
className={cn(
|
||||
"absolute h-4 w-4 text-muted-foreground transition-opacity",
|
||||
selectionState === "none"
|
||||
? "max-sm:opacity-0 group-hover/item:opacity-0"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<Checkbox
|
||||
checked={
|
||||
selectionState === "all"
|
||||
? true
|
||||
: selectionState === "some"
|
||||
? "indeterminate"
|
||||
: false
|
||||
}
|
||||
aria-label={`Select ${folder.name}`}
|
||||
onCheckedChange={handleCheckChange}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
"absolute h-3.5 w-3.5 transition-opacity max-sm:pointer-events-auto max-sm:opacity-100",
|
||||
selectionState === "none"
|
||||
? "pointer-events-none opacity-0 group-hover/item:pointer-events-auto group-hover/item:opacity-100 focus-visible:pointer-events-auto focus-visible:opacity-100"
|
||||
: "opacity-100"
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{isRenaming ? (
|
||||
<input
|
||||
|
|
@ -350,91 +361,109 @@ export const FolderNode = React.memo(function FolderNode({
|
|||
)}
|
||||
|
||||
{!isRenaming && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hidden sm:inline-flex h-6 w-6 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
{isWatched && onRescan && (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute top-0 right-0 bottom-0 flex items-center rounded-r-md pr-1 pl-6",
|
||||
dropdownOpen
|
||||
? "bg-gradient-to-l from-accent from-60% to-transparent"
|
||||
: "bg-gradient-to-l from-sidebar from-60% to-transparent group-hover/item:from-accent",
|
||||
isMobile
|
||||
? "opacity-0"
|
||||
: dropdownOpen
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover/item:opacity-100"
|
||||
)}
|
||||
>
|
||||
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"pointer-events-auto hidden h-6 w-6 shrink-0 hover:bg-transparent transition-opacity sm:inline-flex",
|
||||
dropdownOpen && "bg-accent hover:bg-accent"
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={`Folder actions for ${folder.name}`}
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
{isWatched && onRescan && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRescan();
|
||||
}}
|
||||
>
|
||||
<RefreshCw className={cn("mr-2 h-4 w-4", isRescanning && "animate-spin")} />
|
||||
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();
|
||||
handleRescan();
|
||||
onCreateSubfolder(folder.id);
|
||||
}}
|
||||
>
|
||||
<RefreshCw className={cn("mr-2 h-4 w-4", isRescanning && "animate-spin")} />
|
||||
Re-scan
|
||||
<FolderPlus className="mr-2 h-4 w-4" />
|
||||
New subfolder
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isWatched && onStopWatching && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onStopWatching(folder);
|
||||
startRename();
|
||||
}}
|
||||
>
|
||||
<EyeOff className="mr-2 h-4 w-4" />
|
||||
Stop watching
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCreateSubfolder(folder.id);
|
||||
}}
|
||||
>
|
||||
<FolderPlus className="mr-2 h-4 w-4" />
|
||||
New subfolder
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startRename();
|
||||
}}
|
||||
>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMove(folder);
|
||||
}}
|
||||
>
|
||||
<Move className="mr-2 h-4 w-4" />
|
||||
Move to...
|
||||
</DropdownMenuItem>
|
||||
{onExportFolder && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onExportFolder(folder);
|
||||
onMove(folder);
|
||||
}}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export folder
|
||||
<Move className="mr-2 h-4 w-4" />
|
||||
Move to...
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(folder);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{onExportFolder && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onExportFolder(folder);
|
||||
}}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export folder
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(folder);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SidebarListItem>
|
||||
</ContextMenuTrigger>
|
||||
|
||||
{!isRenaming && contextMenuOpen && (
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ interface FolderTreeViewProps {
|
|||
onMoveFolder: (folder: FolderDisplay) => void;
|
||||
onCreateFolder: (parentId: number | null) => void;
|
||||
onPreviewDocument: (doc: DocumentNodeDoc) => void;
|
||||
onEditDocument: (doc: DocumentNodeDoc) => void;
|
||||
onDeleteDocument: (doc: DocumentNodeDoc) => void;
|
||||
onMoveDocument: (doc: DocumentNodeDoc) => void;
|
||||
onResetDocument?: (doc: DocumentNodeDoc) => void;
|
||||
|
|
@ -72,7 +71,6 @@ export function FolderTreeView({
|
|||
onMoveFolder,
|
||||
onCreateFolder,
|
||||
onPreviewDocument,
|
||||
onEditDocument,
|
||||
onDeleteDocument,
|
||||
onMoveDocument,
|
||||
onResetDocument,
|
||||
|
|
@ -93,8 +91,6 @@ export function FolderTreeView({
|
|||
|
||||
const [openContextMenuId, setOpenContextMenuId] = useState<string | null>(null);
|
||||
|
||||
const [manuallyCollapsedAiIds, setManuallyCollapsedAiIds] = useState<Set<number>>(new Set());
|
||||
|
||||
// Single subscription for rename state — derived boolean passed to each FolderNode
|
||||
const [renamingFolderId, setRenamingFolderId] = useAtom(renamingFolderIdAtom);
|
||||
const handleStartRename = useCallback(
|
||||
|
|
@ -103,38 +99,6 @@ export function FolderTreeView({
|
|||
);
|
||||
const handleCancelRename = useCallback(() => setRenamingFolderId(null), [setRenamingFolderId]);
|
||||
|
||||
const aiSortFolderLevels = useMemo(() => {
|
||||
const map = new Map<number, number>();
|
||||
for (const f of folders) {
|
||||
if (f.metadata?.ai_sort === true && typeof f.metadata?.ai_sort_level === "number") {
|
||||
map.set(f.id, f.metadata.ai_sort_level as number);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [folders]);
|
||||
|
||||
const handleToggleExpand = useCallback(
|
||||
(folderId: number) => {
|
||||
const aiLevel = aiSortFolderLevels.get(folderId);
|
||||
if (aiLevel !== undefined && aiLevel < 4) {
|
||||
// AI-auto-expanded folder: only toggle the manual-collapse set.
|
||||
// Calling onToggleExpand would add it to expandedIds and fight auto-expand.
|
||||
setManuallyCollapsedAiIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(folderId)) {
|
||||
next.delete(folderId);
|
||||
} else {
|
||||
next.add(folderId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
onToggleExpand(folderId);
|
||||
},
|
||||
[onToggleExpand, aiSortFolderLevels]
|
||||
);
|
||||
|
||||
const effectiveActiveTypes = useMemo(() => {
|
||||
if (
|
||||
activeTypes.includes("FILE" as DocumentTypeEnum) &&
|
||||
|
|
@ -249,7 +213,6 @@ export function FolderTreeView({
|
|||
isMentioned={!isMemoryDocument && mentionedDocKeys.has(getMentionDocKey(d))}
|
||||
onToggleChatMention={onToggleChatMention}
|
||||
onPreview={onPreviewDocument}
|
||||
onEdit={onEditDocument}
|
||||
onDelete={onDeleteDocument}
|
||||
onMove={onMoveDocument}
|
||||
onReset={onResetDocument}
|
||||
|
|
@ -258,7 +221,6 @@ export function FolderTreeView({
|
|||
canDelete={!isMemoryDocument}
|
||||
canMove={!isMemoryDocument}
|
||||
canMention={!isMemoryDocument}
|
||||
canEdit
|
||||
contextMenuOpen={openContextMenuId === `doc-${d.id}`}
|
||||
onContextMenuOpenChange={(open) => setOpenContextMenuId(open ? `doc-${d.id}` : null)}
|
||||
/>
|
||||
|
|
@ -267,7 +229,6 @@ export function FolderTreeView({
|
|||
[
|
||||
mentionedDocKeys,
|
||||
onDeleteDocument,
|
||||
onEditDocument,
|
||||
onExportDocument,
|
||||
onMoveDocument,
|
||||
onPreviewDocument,
|
||||
|
|
@ -280,14 +241,9 @@ export function FolderTreeView({
|
|||
|
||||
function renderLevel(parentId: number | null, depth: number): React.ReactNode[] {
|
||||
const key = parentId ?? "root";
|
||||
const childFolders = (foldersByParent[key] ?? []).slice().sort((a, b) => {
|
||||
const aIsDate = a.metadata?.ai_sort === true && a.metadata?.ai_sort_level === 2;
|
||||
const bIsDate = b.metadata?.ai_sort === true && b.metadata?.ai_sort_level === 2;
|
||||
if (aIsDate && bIsDate) {
|
||||
return b.name.localeCompare(a.name);
|
||||
}
|
||||
return a.position.localeCompare(b.position);
|
||||
});
|
||||
const childFolders = (foldersByParent[key] ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => a.position.localeCompare(b.position));
|
||||
const visibleFolders = hasDescendantMatch
|
||||
? childFolders.filter((f) => hasDescendantMatch[f.id])
|
||||
: childFolders;
|
||||
|
|
@ -299,16 +255,6 @@ export function FolderTreeView({
|
|||
|
||||
const nodes: React.ReactNode[] = [];
|
||||
|
||||
if (parentId === null) {
|
||||
const processingDocs = childDocs.filter((d) => {
|
||||
const state = d.status?.state;
|
||||
return state === "pending" || state === "processing";
|
||||
});
|
||||
for (const d of processingDocs) {
|
||||
nodes.push(renderDocumentNode(d, depth));
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < visibleFolders.length; i++) {
|
||||
const f = visibleFolders[i];
|
||||
const siblingPositions = {
|
||||
|
|
@ -317,14 +263,7 @@ export function FolderTreeView({
|
|||
};
|
||||
|
||||
const isSearchAutoExpanded = !!searchQuery && !!hasDescendantMatch?.[f.id];
|
||||
const isAiAutoExpandCandidate =
|
||||
f.metadata?.ai_sort === true &&
|
||||
typeof f.metadata?.ai_sort_level === "number" &&
|
||||
(f.metadata.ai_sort_level as number) < 4;
|
||||
const isManuallyCollapsed = manuallyCollapsedAiIds.has(f.id);
|
||||
const isExpanded = isManuallyCollapsed
|
||||
? isSearchAutoExpanded
|
||||
: expandedIds.has(f.id) || isSearchAutoExpanded || isAiAutoExpandCandidate;
|
||||
const isExpanded = expandedIds.has(f.id) || isSearchAutoExpanded;
|
||||
|
||||
nodes.push(
|
||||
<FolderNode
|
||||
|
|
@ -336,7 +275,7 @@ export function FolderTreeView({
|
|||
selectionState={folderSelectionStates[f.id] ?? "none"}
|
||||
processingState={folderProcessingStates[f.id] ?? "idle"}
|
||||
onToggleSelect={onToggleFolderSelect}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
onToggleExpand={onToggleExpand}
|
||||
onRename={onRenameFolder}
|
||||
onStartRename={handleStartRename}
|
||||
onCancelRename={handleCancelRename}
|
||||
|
|
@ -360,15 +299,7 @@ export function FolderTreeView({
|
|||
}
|
||||
}
|
||||
|
||||
const remainingDocs =
|
||||
parentId === null
|
||||
? childDocs.filter((d) => {
|
||||
const state = d.status?.state;
|
||||
return state !== "pending" && state !== "processing";
|
||||
})
|
||||
: childDocs;
|
||||
|
||||
for (const d of remainingDocs) {
|
||||
for (const d of childDocs) {
|
||||
nodes.push(renderDocumentNode(d, depth));
|
||||
}
|
||||
|
||||
|
|
@ -382,7 +313,7 @@ export function FolderTreeView({
|
|||
<div className="flex flex-1 flex-col items-center justify-center gap-1 px-4 py-12 text-muted-foreground select-none">
|
||||
<p className="text-sm font-medium">No documents found</p>
|
||||
<p className="text-xs text-muted-foreground/70">
|
||||
Use the upload button or connect a source above
|
||||
Use the Import button above to add files, or the plus menu to manage connectors
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -393,14 +324,13 @@ export function FolderTreeView({
|
|||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-4 py-12 text-muted-foreground">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-2 py-1">{treeNodes}</div>
|
||||
<div className="px-2 py-1">{treeNodes}</div>
|
||||
</DndProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { useAtomValue, useSetAtom } from "jotai";
|
|||
import {
|
||||
Check,
|
||||
Copy,
|
||||
Download,
|
||||
FileQuestionMark,
|
||||
FileText,
|
||||
Pencil,
|
||||
|
|
@ -146,7 +145,7 @@ export function EditorPanelContent({
|
|||
documentId,
|
||||
localFilePath,
|
||||
memoryScope,
|
||||
searchSpaceId,
|
||||
workspaceId,
|
||||
title,
|
||||
onClose,
|
||||
}: {
|
||||
|
|
@ -154,7 +153,7 @@ export function EditorPanelContent({
|
|||
documentId?: number;
|
||||
localFilePath?: string;
|
||||
memoryScope?: "user" | "team";
|
||||
searchSpaceId?: number;
|
||||
workspaceId?: number;
|
||||
title: string | null;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
|
|
@ -163,7 +162,6 @@ 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 [isEditing, setIsEditing] = useState(false);
|
||||
const [memoryLimits, setMemoryLimits] = useState<MemoryLimits | null>(null);
|
||||
|
||||
|
|
@ -186,14 +184,14 @@ export function EditorPanelContent({
|
|||
}
|
||||
try {
|
||||
const mounts = (await electronAPI.getAgentFilesystemMounts(
|
||||
searchSpaceId
|
||||
workspaceId
|
||||
)) as AgentFilesystemMount[];
|
||||
return normalizeLocalVirtualPathForEditor(candidatePath, mounts);
|
||||
} catch {
|
||||
return candidatePath;
|
||||
}
|
||||
},
|
||||
[electronAPI, searchSpaceId]
|
||||
[electronAPI, workspaceId]
|
||||
);
|
||||
|
||||
const plateMaxBytes = editorDoc?.editor_plate_max_bytes ?? LARGE_DOCUMENT_THRESHOLD;
|
||||
|
|
@ -234,7 +232,7 @@ export function EditorPanelContent({
|
|||
const resolvedLocalPath = await resolveLocalVirtualPath(localFilePath);
|
||||
const readResult = await electronAPI.readAgentLocalFileText(
|
||||
resolvedLocalPath,
|
||||
searchSpaceId
|
||||
workspaceId
|
||||
);
|
||||
if (!readResult.ok) {
|
||||
throw new Error(readResult.error || "Failed to read local file");
|
||||
|
|
@ -257,7 +255,7 @@ export function EditorPanelContent({
|
|||
if (!memoryScope) throw new Error("Missing memory context");
|
||||
const { document, limits } = await fetchMemoryEditorDocument({
|
||||
scope: memoryScope,
|
||||
searchSpaceId,
|
||||
workspaceId,
|
||||
title,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
|
@ -271,12 +269,12 @@ export function EditorPanelContent({
|
|||
return;
|
||||
}
|
||||
|
||||
if (!documentId || !searchSpaceId) {
|
||||
if (!documentId || !workspaceId) {
|
||||
throw new Error("Missing document context");
|
||||
}
|
||||
const response = await authenticatedFetch(
|
||||
buildBackendUrl(
|
||||
`/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content`
|
||||
`/api/v1/workspaces/${workspaceId}/documents/${documentId}/editor-content`
|
||||
),
|
||||
{ method: "GET" }
|
||||
);
|
||||
|
|
@ -323,7 +321,7 @@ export function EditorPanelContent({
|
|||
localFilePath,
|
||||
memoryScope,
|
||||
resolveLocalVirtualPath,
|
||||
searchSpaceId,
|
||||
workspaceId,
|
||||
title,
|
||||
]);
|
||||
|
||||
|
|
@ -382,7 +380,7 @@ export function EditorPanelContent({
|
|||
const writeResult = await electronAPI.writeAgentLocalFileText(
|
||||
resolvedLocalPath,
|
||||
contentToSave,
|
||||
searchSpaceId
|
||||
workspaceId
|
||||
);
|
||||
if (!writeResult.ok) {
|
||||
throw new Error(writeResult.error || "Failed to save local file");
|
||||
|
|
@ -395,7 +393,7 @@ export function EditorPanelContent({
|
|||
if (!memoryScope) throw new Error("Missing memory context");
|
||||
const { markdown: savedContent, limits } = await saveMemoryMarkdown({
|
||||
scope: memoryScope,
|
||||
searchSpaceId,
|
||||
workspaceId,
|
||||
markdown: markdownRef.current,
|
||||
});
|
||||
markdownRef.current = savedContent;
|
||||
|
|
@ -408,11 +406,11 @@ export function EditorPanelContent({
|
|||
return true;
|
||||
}
|
||||
|
||||
if (!searchSpaceId || !documentId) {
|
||||
if (!workspaceId || !documentId) {
|
||||
throw new Error("Missing document context");
|
||||
}
|
||||
const response = await authenticatedFetch(
|
||||
buildBackendUrl(`/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/save`),
|
||||
buildBackendUrl(`/api/v1/workspaces/${workspaceId}/documents/${documentId}/save`),
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
|
@ -460,7 +458,7 @@ export function EditorPanelContent({
|
|||
plateMaxBytes,
|
||||
plateMaxLines,
|
||||
resolveLocalVirtualPath,
|
||||
searchSpaceId,
|
||||
workspaceId,
|
||||
]
|
||||
);
|
||||
|
||||
|
|
@ -514,88 +512,18 @@ export function EditorPanelContent({
|
|||
setIsEditing(false);
|
||||
}, [editorDoc?.source_markdown]);
|
||||
|
||||
const handleDownloadMarkdown = useCallback(async () => {
|
||||
if (!searchSpaceId || !documentId) return;
|
||||
setDownloading(true);
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
buildBackendUrl(
|
||||
`/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);
|
||||
}
|
||||
}, [documentId, editorDoc?.title, searchSpaceId]);
|
||||
|
||||
const largeDocAlert = viewerMode === "monaco" && !isLocalFileMode && editorDoc && (
|
||||
<Alert className="m-4 shrink-0">
|
||||
<FileText className="size-4" />
|
||||
<AlertDescription className="flex items-center justify-between gap-4">
|
||||
<span>
|
||||
This document is too large for the editor (
|
||||
{formatBytes(editorDoc.content_size_bytes ?? 0)}, {docLineCount.toLocaleString()} lines,{" "}
|
||||
{editorDoc.chunk_count ?? 0} chunks). Showing raw markdown below.
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="relative shrink-0"
|
||||
disabled={downloading}
|
||||
onClick={handleDownloadMarkdown}
|
||||
>
|
||||
<span className={`flex items-center gap-1.5 ${downloading ? "opacity-0" : ""}`}>
|
||||
<Download className="size-3.5" />
|
||||
Download .md
|
||||
</span>
|
||||
{downloading && <Spinner size="sm" className="absolute" />}
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showDesktopHeader ? (
|
||||
<div className="shrink-0">
|
||||
<div className="shrink-0 flex h-12 items-center justify-between px-3 border-b">
|
||||
<h2 className="select-none text-lg font-semibold">File</h2>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="h-8 w-8 rounded-full shrink-0 text-muted-foreground hover:text-accent-foreground"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
<span className="sr-only">Close editor panel</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid h-10 grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border-b px-4">
|
||||
<div className="grid h-12 grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border-b px-4">
|
||||
<div className="min-w-0 flex flex-1 items-center gap-2">
|
||||
<p className="truncate text-sm text-muted-foreground">{displayTitle}</p>
|
||||
{memoryLimitState && (
|
||||
<>
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="mx-1 bg-border data-[orientation=vertical]:h-4 data-[orientation=vertical]:w-px dark:bg-white/10"
|
||||
className="mx-1.5 bg-muted-foreground/20 data-[orientation=vertical]:h-4 data-[orientation=vertical]:w-px dark:bg-muted-foreground/25"
|
||||
/>
|
||||
<span className={`shrink-0 text-xs ${memoryCounterClassName}`}>
|
||||
{memoryLimitState.label}
|
||||
|
|
@ -671,6 +599,19 @@ export function EditorPanelContent({
|
|||
)}
|
||||
</>
|
||||
)}
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="mx-1.5 bg-muted-foreground/20 data-[orientation=vertical]:h-4 data-[orientation=vertical]:w-px dark:bg-muted-foreground/25"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="size-6 shrink-0 rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close editor panel</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -808,7 +749,6 @@ export function EditorPanelContent({
|
|||
) : viewerMode === "monaco" && !isLocalFileMode ? (
|
||||
// Large doc — raw markdown in Monaco. Rich renderers are intentionally skipped.
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{largeDocAlert}
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<SourceCodeEditor
|
||||
path={`${editorDoc.title || "document"}.md`}
|
||||
|
|
@ -890,7 +830,7 @@ function DesktopEditorPanel() {
|
|||
|
||||
const hasTarget =
|
||||
panelState.kind === "document"
|
||||
? !!panelState.documentId && !!panelState.searchSpaceId
|
||||
? !!panelState.documentId && !!panelState.workspaceId
|
||||
: panelState.kind === "local_file"
|
||||
? !!panelState.localFilePath
|
||||
: !!panelState.memoryScope;
|
||||
|
|
@ -903,7 +843,7 @@ function DesktopEditorPanel() {
|
|||
documentId={panelState.documentId ?? undefined}
|
||||
localFilePath={panelState.localFilePath ?? undefined}
|
||||
memoryScope={panelState.memoryScope ?? undefined}
|
||||
searchSpaceId={panelState.searchSpaceId ?? undefined}
|
||||
workspaceId={panelState.workspaceId ?? undefined}
|
||||
title={panelState.title}
|
||||
onClose={closePanel}
|
||||
/>
|
||||
|
|
@ -919,7 +859,7 @@ function MobileEditorDrawer() {
|
|||
|
||||
const hasTarget =
|
||||
panelState.kind === "document"
|
||||
? !!panelState.documentId && !!panelState.searchSpaceId
|
||||
? !!panelState.documentId && !!panelState.workspaceId
|
||||
: !!panelState.memoryScope;
|
||||
if (!hasTarget) return null;
|
||||
|
||||
|
|
@ -943,7 +883,7 @@ function MobileEditorDrawer() {
|
|||
documentId={panelState.documentId ?? undefined}
|
||||
localFilePath={panelState.localFilePath ?? undefined}
|
||||
memoryScope={panelState.memoryScope ?? undefined}
|
||||
searchSpaceId={panelState.searchSpaceId ?? undefined}
|
||||
workspaceId={panelState.workspaceId ?? undefined}
|
||||
title={panelState.title}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -957,7 +897,7 @@ export function EditorPanel() {
|
|||
const isDesktop = useMediaQuery("(min-width: 1024px)");
|
||||
const hasTarget =
|
||||
panelState.kind === "document"
|
||||
? !!panelState.documentId && !!panelState.searchSpaceId
|
||||
? !!panelState.documentId && !!panelState.workspaceId
|
||||
: panelState.kind === "local_file"
|
||||
? !!panelState.localFilePath
|
||||
: !!panelState.memoryScope;
|
||||
|
|
@ -977,7 +917,7 @@ export function MobileEditorPanel() {
|
|||
const isDesktop = useMediaQuery("(min-width: 1024px)");
|
||||
const hasTarget =
|
||||
panelState.kind === "document"
|
||||
? !!panelState.documentId && !!panelState.searchSpaceId
|
||||
? !!panelState.documentId && !!panelState.workspaceId
|
||||
: panelState.kind === "local_file"
|
||||
? !!panelState.localFilePath
|
||||
: !!panelState.memoryScope;
|
||||
|
|
|
|||
|
|
@ -24,10 +24,10 @@ interface MemoryReadResponse {
|
|||
limits?: MemoryLimits;
|
||||
}
|
||||
|
||||
function getMemoryPath(scope: MemoryScope, searchSpaceId?: number | null) {
|
||||
function getMemoryPath(scope: MemoryScope, workspaceId?: number | null) {
|
||||
if (scope === "user") return "/api/v1/users/me/memory";
|
||||
if (!searchSpaceId) throw new Error("Missing search space context");
|
||||
return `/api/v1/searchspaces/${searchSpaceId}/memory`;
|
||||
if (!workspaceId) throw new Error("Missing workspace context");
|
||||
return `/api/v1/workspaces/${workspaceId}/memory`;
|
||||
}
|
||||
|
||||
export function getMemoryLimitState(length: number, limits?: MemoryLimits | null) {
|
||||
|
|
@ -53,16 +53,16 @@ export function getMemoryLimitState(length: number, limits?: MemoryLimits | null
|
|||
|
||||
export async function fetchMemoryEditorDocument({
|
||||
scope,
|
||||
searchSpaceId,
|
||||
workspaceId,
|
||||
title,
|
||||
signal,
|
||||
}: {
|
||||
scope: MemoryScope;
|
||||
searchSpaceId?: number | null;
|
||||
workspaceId?: number | null;
|
||||
title?: string | null;
|
||||
signal?: AbortSignal;
|
||||
}) {
|
||||
const response = await authenticatedFetch(buildBackendUrl(getMemoryPath(scope, searchSpaceId)), {
|
||||
const response = await authenticatedFetch(buildBackendUrl(getMemoryPath(scope, workspaceId)), {
|
||||
method: "GET",
|
||||
signal,
|
||||
});
|
||||
|
|
@ -87,14 +87,14 @@ export async function fetchMemoryEditorDocument({
|
|||
|
||||
export async function saveMemoryMarkdown({
|
||||
scope,
|
||||
searchSpaceId,
|
||||
workspaceId,
|
||||
markdown,
|
||||
}: {
|
||||
scope: MemoryScope;
|
||||
searchSpaceId?: number | null;
|
||||
workspaceId?: number | null;
|
||||
markdown: string;
|
||||
}) {
|
||||
const response = await authenticatedFetch(buildBackendUrl(getMemoryPath(scope, searchSpaceId)), {
|
||||
const response = await authenticatedFetch(buildBackendUrl(getMemoryPath(scope, workspaceId)), {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ memory_md: markdown }),
|
||||
|
|
|
|||
|
|
@ -117,7 +117,6 @@ export function FreeChatPage() {
|
|||
const anonMode = useAnonymousMode();
|
||||
const modelSlug = anonMode.isAnonymous ? anonMode.modelSlug : "";
|
||||
const resetKey = anonMode.isAnonymous ? anonMode.resetKey : 0;
|
||||
const webSearchEnabled = anonMode.isAnonymous ? anonMode.webSearchEnabled : true;
|
||||
|
||||
const [messages, setMessages] = useState<ThreadMessageLike[]>([]);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
|
|
@ -173,7 +172,6 @@ export function FreeChatPage() {
|
|||
model_slug: modelSlug,
|
||||
messages: messageHistory,
|
||||
};
|
||||
if (!webSearchEnabled) reqBody.disabled_tools = ["web_search"];
|
||||
if (turnstileToken) reqBody.turnstile_token = turnstileToken;
|
||||
|
||||
const response = await fetch(buildBackendUrl("/api/v1/public/anon-chat/stream"), {
|
||||
|
|
@ -323,7 +321,7 @@ export function FreeChatPage() {
|
|||
throw err;
|
||||
}
|
||||
},
|
||||
[modelSlug, tokenUsageStore, webSearchEnabled]
|
||||
[modelSlug, tokenUsageStore]
|
||||
);
|
||||
|
||||
const onNew = useCallback(
|
||||
|
|
|
|||
|
|
@ -1,13 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import { ComposerPrimitive, useAui, useAuiState } from "@assistant-ui/react";
|
||||
import { ArrowUpIcon, Globe, Paperclip, SquareIcon } from "lucide-react";
|
||||
import { ArrowUpIcon, Paperclip, SquareIcon } from "lucide-react";
|
||||
import { type FC, useCallback, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useAnonymousMode } from "@/contexts/anonymous-mode";
|
||||
import { useLoginGate } from "@/contexts/login-gate";
|
||||
|
|
@ -76,8 +74,6 @@ export const FreeComposer: FC = () => {
|
|||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const hasUploadedDoc = anonMode.isAnonymous && anonMode.uploadedDoc !== null;
|
||||
const webSearchEnabled = anonMode.isAnonymous ? anonMode.webSearchEnabled : true;
|
||||
const setWebSearchEnabled = anonMode.isAnonymous ? anonMode.setWebSearchEnabled : () => {};
|
||||
|
||||
const handleTextChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
|
|
@ -208,26 +204,6 @@ export const FreeComposer: FC = () => {
|
|||
: "Upload a document (text files only)"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<label
|
||||
htmlFor="free-web-search-toggle"
|
||||
className="flex cursor-pointer select-none items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<Globe className="size-3.5" />
|
||||
<span className="hidden sm:inline">Web</span>
|
||||
<Switch
|
||||
id="free-web-search-toggle"
|
||||
checked={webSearchEnabled}
|
||||
onCheckedChange={setWebSearchEnabled}
|
||||
/>
|
||||
</label>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Toggle web search</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
|
|
|
|||
55
surfsense_web/components/homepage/community-strip.tsx
Normal file
55
surfsense_web/components/homepage/community-strip.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { IconBrandDiscord, IconBrandGithub, IconBrandReddit } from "@tabler/icons-react";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Reveal } from "@/components/connectors-marketing/reveal";
|
||||
import { MarketingSection } from "@/components/marketing/section";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const GITHUB_URL = "https://github.com/MODSetter/SurfSense";
|
||||
const DISCORD_URL = "https://discord.gg/ejRNvftDp9";
|
||||
const REDDIT_URL = "https://www.reddit.com/r/SurfSense/";
|
||||
|
||||
/** Closing CTA doubling as the GitHub/community strip (brief section 7). */
|
||||
export function CommunityStrip() {
|
||||
return (
|
||||
<MarketingSection>
|
||||
<Reveal>
|
||||
<div className="rounded-2xl border bg-card p-8 text-center sm:p-12">
|
||||
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
Open source, built in public
|
||||
</h2>
|
||||
<p className="mx-auto mt-3 max-w-xl text-muted-foreground leading-relaxed">
|
||||
No CI incumbent can show you its code. SurfSense can. Star the repository, join the
|
||||
community, or self-host the whole platform. Start free, no credit card required.
|
||||
</p>
|
||||
<div className="mt-7 flex flex-wrap justify-center gap-3">
|
||||
<Button asChild size="lg">
|
||||
<Link href="/register">
|
||||
Start for free
|
||||
<ArrowRight className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="lg">
|
||||
<Link href={GITHUB_URL} target="_blank" rel="noopener noreferrer">
|
||||
<IconBrandGithub className="size-4" />
|
||||
Star on GitHub
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="lg">
|
||||
<Link href={DISCORD_URL} target="_blank" rel="noopener noreferrer">
|
||||
<IconBrandDiscord className="size-4" />
|
||||
Join Discord
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="lg">
|
||||
<Link href={REDDIT_URL} target="_blank" rel="noopener noreferrer">
|
||||
<IconBrandReddit className="size-4" />
|
||||
r/SurfSense
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</MarketingSection>
|
||||
);
|
||||
}
|
||||
107
surfsense_web/components/homepage/compare-table.tsx
Normal file
107
surfsense_web/components/homepage/compare-table.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { Reveal } from "@/components/connectors-marketing/reveal";
|
||||
import { MarketingSection } from "@/components/marketing/section";
|
||||
|
||||
const COLUMNS = [
|
||||
{ label: "Browser agents", examples: "Browserbase, Browser Use" },
|
||||
{ label: "Scraping APIs", examples: "Firecrawl" },
|
||||
{ label: "Search APIs", examples: "Exa, Tavily, Parallel" },
|
||||
{ label: "Scraper marketplaces", examples: "Apify" },
|
||||
];
|
||||
|
||||
const ROWS = [
|
||||
{
|
||||
feature: "Built for",
|
||||
browser: "Web tasks that need clicking, logins, and forms",
|
||||
scraping: "Turning individual pages into LLM-ready content",
|
||||
search: "Finding and reading pages about a topic",
|
||||
marketplace: "Thousands of community-built scrapers, one per site",
|
||||
surfsense: "Live platform data as research primitives for agents",
|
||||
},
|
||||
{
|
||||
feature: "How retrieval works",
|
||||
browser: "An LLM drives a real browser, page by page",
|
||||
scraping: "Fetch a URL, get markdown or schema-extracted JSON",
|
||||
search: "Query an index, get ranked results and page content",
|
||||
marketplace: "Pick an actor per site, learn its input, run it",
|
||||
surfsense: "One typed REST call per platform, no LLM in the retrieval loop",
|
||||
},
|
||||
{
|
||||
feature: "Platform data (comment trees, transcripts, reviews)",
|
||||
browser: "Whatever the LLM extracts from rendered pages",
|
||||
scraping: "Page-level extraction; social platforms aren't the focus",
|
||||
search: "Page text and snippets, not structured platform items",
|
||||
marketplace: "Yes, but schema and quality vary per actor",
|
||||
surfsense: "Native items: posts, comment trees, transcripts, reviews, SERPs",
|
||||
},
|
||||
{
|
||||
feature: "Consistency",
|
||||
browser: "Depends on the model and the page",
|
||||
scraping: "One API, you define schemas per page type",
|
||||
search: "One API, snippet and page-content shapes",
|
||||
marketplace: "A different schema and quality bar per actor",
|
||||
surfsense: "One API and one schema style across every connector",
|
||||
},
|
||||
{
|
||||
feature: "Research workspace & knowledge base",
|
||||
browser: "No",
|
||||
scraping: "No",
|
||||
search: "No",
|
||||
marketplace: "No",
|
||||
surfsense: "Cited briefs, knowledge base, scheduled automations, deliverables",
|
||||
},
|
||||
{
|
||||
feature: "Pricing",
|
||||
browser: "Per browser minute (1-minute session minimum) plus the LLM tokens driving it",
|
||||
scraping: "Credits per page; schema extraction costs extra credits",
|
||||
search: "Per search request",
|
||||
marketplace: "Per event or result, set by each actor",
|
||||
surfsense: "Per item returned; failed calls never billed",
|
||||
},
|
||||
];
|
||||
|
||||
export function CompareTable() {
|
||||
return (
|
||||
<MarketingSection>
|
||||
<Reveal>
|
||||
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">How SurfSense compares</h2>
|
||||
<p className="mt-3 max-w-2xl text-muted-foreground leading-relaxed">
|
||||
SurfSense is the only open-source product that combines a NotebookLM-style research
|
||||
workspace for people with live-data primitives for agents. Here is how that stacks up
|
||||
against each class of tool.
|
||||
</p>
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
<div className="mt-8 overflow-x-auto rounded-xl border bg-card">
|
||||
<table className="w-full min-w-4xl text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/40 text-left">
|
||||
<th className="p-4 font-medium">Feature</th>
|
||||
{COLUMNS.map((col) => (
|
||||
<th key={col.label} className="p-4 font-medium text-muted-foreground">
|
||||
{col.label}
|
||||
<span className="block text-xs font-normal">{col.examples}</span>
|
||||
</th>
|
||||
))}
|
||||
<th className="p-4 font-medium text-brand">SurfSense</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ROWS.map((row) => (
|
||||
<tr key={row.feature} className="border-b last:border-b-0">
|
||||
<th scope="row" className="p-4 text-left font-medium">
|
||||
{row.feature}
|
||||
</th>
|
||||
<td className="p-4 text-muted-foreground">{row.browser}</td>
|
||||
<td className="p-4 text-muted-foreground">{row.scraping}</td>
|
||||
<td className="p-4 text-muted-foreground">{row.search}</td>
|
||||
<td className="p-4 text-muted-foreground">{row.marketplace}</td>
|
||||
<td className="bg-brand/5 p-4 text-foreground">{row.surfsense}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Reveal>
|
||||
</MarketingSection>
|
||||
);
|
||||
}
|
||||
68
surfsense_web/components/homepage/connector-grid.tsx
Normal file
68
surfsense_web/components/homepage/connector-grid.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { ArrowRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Reveal } from "@/components/connectors-marketing/reveal";
|
||||
import { MarketingSection } from "@/components/marketing/section";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getAllConnectors } from "@/lib/connectors-marketing";
|
||||
|
||||
/** Registry-driven connector grid with a live count badge (brief: never list connectors in copy). */
|
||||
export function ConnectorGrid() {
|
||||
const connectors = getAllConnectors();
|
||||
|
||||
return (
|
||||
<MarketingSection>
|
||||
<Reveal>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
Connectors for every platform where answers live
|
||||
</h2>
|
||||
<Badge variant="outline" className="py-1">
|
||||
{connectors.length} connectors and growing
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-3 max-w-2xl text-muted-foreground leading-relaxed">
|
||||
Each connector is a platform-native REST API. Call it from your own app with your
|
||||
SurfSense API key, or hand it to your agents through the SurfSense MCP server. Live data
|
||||
in, structured intelligence out.
|
||||
</p>
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
<div className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{connectors.map((connector) => {
|
||||
const Icon = connector.icon;
|
||||
return (
|
||||
<Link
|
||||
key={connector.slug}
|
||||
href={`/${connector.slug}`}
|
||||
className="group flex flex-col rounded-xl border bg-card p-5 transition-colors hover:border-brand/40"
|
||||
>
|
||||
<span className="flex size-10 items-center justify-center rounded-lg border bg-muted/40 transition-transform duration-200 ease-out group-hover:scale-110 motion-reduce:transition-none motion-reduce:group-hover:scale-100">
|
||||
<Icon className="size-5 text-foreground" />
|
||||
</span>
|
||||
<h3 className="mt-3 font-semibold">
|
||||
{connector.cardTitle ?? `${connector.name} API`}
|
||||
</h3>
|
||||
<p className="mt-1.5 flex-1 text-sm leading-relaxed text-muted-foreground line-clamp-2">
|
||||
{connector.heroLede}
|
||||
</p>
|
||||
<span className="mt-3 inline-flex items-center gap-1 text-sm font-medium text-foreground">
|
||||
Explore
|
||||
<ArrowRight className="size-4 transition-transform group-hover:translate-x-0.5" />
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/connectors">
|
||||
View all connectors
|
||||
<ArrowRight className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</Reveal>
|
||||
</MarketingSection>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
"use client";
|
||||
import { IconMessageCircleQuestion } from "@tabler/icons-react";
|
||||
import Link from "next/link";
|
||||
import type React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function CTAHomepage() {
|
||||
return (
|
||||
<section className="w-full grid grid-cols-1 md:grid-cols-3 my-20 md:my-20 justify-start relative z-20 max-w-7xl mx-auto bg-gradient-to-br from-gray-100 to-white dark:from-neutral-900 dark:to-neutral-950">
|
||||
<GridLineHorizontal className="top-0" offset="200px" />
|
||||
<GridLineHorizontal className="bottom-0 top-auto" offset="200px" />
|
||||
<GridLineVertical className="left-0" offset="80px" />
|
||||
<GridLineVertical className="left-auto right-0" offset="80px" />
|
||||
<div className="md:col-span-2 p-8 md:p-14">
|
||||
<h2 className="text-left text-neutral-500 dark:text-neutral-200 text-xl md:text-3xl tracking-tight font-medium">
|
||||
Transform how your team{" "}
|
||||
<span className="font-bold text-black dark:text-white">discovers and collaborates</span>
|
||||
</h2>
|
||||
<p className="text-left text-neutral-500 mt-4 max-w-lg dark:text-neutral-200 text-xl md:text-3xl tracking-tight font-medium">
|
||||
Unite your <span className="text-sky-700">team's knowledge</span> in one collaborative
|
||||
space with <span className="text-indigo-700">intelligent search</span>.
|
||||
</p>
|
||||
|
||||
<div className="flex items-start sm:items-center flex-col sm:flex-row sm:gap-4">
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
className="mt-8 h-auto gap-2 rounded-lg border border-neutral-200 px-4 py-2 text-base text-black shadow-[0px_2px_0px_0px_rgba(255,255,255,0.3)_inset] dark:border-neutral-800 dark:text-white"
|
||||
>
|
||||
<Link href="/contact" className="group">
|
||||
<span>Talk to us</span>
|
||||
<IconMessageCircleQuestion className="text-black dark:text-white group-hover:translate-x-1 stroke-[1px] h-3 w-3 mt-0.5 transition-transform duration-200" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/* <div className="border-t md:border-t-0 md:border-l border-dashed p-8 md:p-14">
|
||||
<p className="text-base text-neutral-700 dark:text-neutral-200">
|
||||
"SurfSense has revolutionized how our team shares and discovers knowledge.
|
||||
Everyone can contribute and find what they need instantly. True collaboration at scale."
|
||||
</p>
|
||||
<div className="flex flex-col text-sm items-start mt-4 gap-1">
|
||||
<p className="font-bold text-neutral-800 dark:text-neutral-200">
|
||||
Sarah Chen
|
||||
</p>
|
||||
<p className="text-neutral-500 dark:text-neutral-400">
|
||||
Research Lead
|
||||
</p>
|
||||
</div>
|
||||
</div> */}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const GridLineHorizontal = ({ className, offset }: { className?: string; offset?: string }) => {
|
||||
return (
|
||||
<div
|
||||
style={
|
||||
{
|
||||
"--background": "#ffffff",
|
||||
"--color": "rgba(0, 0, 0, 0.2)",
|
||||
"--height": "1px",
|
||||
"--width": "5px",
|
||||
"--fade-stop": "90%",
|
||||
"--offset": offset || "200px", //-100px if you want to keep the line inside
|
||||
"--color-dark": "rgba(255, 255, 255, 0.2)",
|
||||
maskComposite: "exclude",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"absolute w-[calc(100%+var(--offset))] h-[var(--height)] left-[calc(var(--offset)/2*-1)]",
|
||||
"bg-[linear-gradient(to_right,var(--color),var(--color)_50%,transparent_0,transparent)]",
|
||||
"[background-size:var(--width)_var(--height)]",
|
||||
"[mask:linear-gradient(to_left,var(--background)_var(--fade-stop),transparent),_linear-gradient(to_right,var(--background)_var(--fade-stop),transparent),_linear-gradient(black,black)]",
|
||||
"[mask-composite:exclude]",
|
||||
"z-30",
|
||||
"dark:bg-[linear-gradient(to_right,var(--color-dark),var(--color-dark)_50%,transparent_0,transparent)]",
|
||||
className
|
||||
)}
|
||||
></div>
|
||||
);
|
||||
};
|
||||
|
||||
const GridLineVertical = ({ className, offset }: { className?: string; offset?: string }) => {
|
||||
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.2)",
|
||||
maskComposite: "exclude",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"absolute h-[calc(100%+var(--offset))] w-[var(--width)] top-[calc(var(--offset)/2*-1)]",
|
||||
"bg-[linear-gradient(to_bottom,var(--color),var(--color)_50%,transparent_0,transparent)]",
|
||||
"[background-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-composite:exclude]",
|
||||
"z-30",
|
||||
"dark:bg-[linear-gradient(to_bottom,var(--color-dark),var(--color-dark)_50%,transparent_0,transparent)]",
|
||||
className
|
||||
)}
|
||||
></div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,991 +0,0 @@
|
|||
import {
|
||||
IconBinaryTree,
|
||||
IconBolt,
|
||||
IconMessage,
|
||||
IconMicrophone,
|
||||
IconSearch,
|
||||
IconUsers,
|
||||
} from "@tabler/icons-react";
|
||||
import Image from "next/image";
|
||||
import React from "react";
|
||||
import { BentoGrid, BentoGridItem } from "@/components/ui/bento-grid";
|
||||
|
||||
export function FeaturesBentoGrid() {
|
||||
return (
|
||||
<BentoGrid className="max-w-7xl my-8 mx-auto md:auto-rows-[20rem]">
|
||||
{items.map((item, i) => (
|
||||
<BentoGridItem
|
||||
key={i}
|
||||
title={item.title}
|
||||
description={item.description}
|
||||
header={item.header}
|
||||
className={item.className}
|
||||
icon={item.icon}
|
||||
/>
|
||||
))}
|
||||
</BentoGrid>
|
||||
);
|
||||
}
|
||||
|
||||
const CitationIllustration = () => (
|
||||
<div className="relative flex w-full h-full min-h-[6rem] items-center justify-center overflow-hidden rounded-xl bg-gradient-to-br from-blue-50 via-purple-50 to-pink-50 dark:from-blue-950/20 dark:via-purple-950/20 dark:to-pink-950/20 p-4">
|
||||
<svg viewBox="0 0 400 200" className="w-full h-full" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Citation feature illustration showing clickable source reference</title>
|
||||
{/* Background chat message */}
|
||||
<g>
|
||||
{/* Chat bubble */}
|
||||
<rect
|
||||
x="20"
|
||||
y="30"
|
||||
width="200"
|
||||
height="60"
|
||||
rx="12"
|
||||
className="fill-white dark:fill-neutral-800"
|
||||
opacity="0.9"
|
||||
/>
|
||||
{/* Text lines */}
|
||||
<line
|
||||
x1="35"
|
||||
y1="50"
|
||||
x2="150"
|
||||
y2="50"
|
||||
className="stroke-neutral-400 dark:stroke-neutral-600"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<line
|
||||
x1="35"
|
||||
y1="65"
|
||||
x2="180"
|
||||
y2="65"
|
||||
className="stroke-neutral-400 dark:stroke-neutral-600"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
{/* Citation badge with glow */}
|
||||
<defs>
|
||||
<filter id="glow">
|
||||
<feGaussianBlur stdDeviation="2" result="coloredBlur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{/* Clickable citation */}
|
||||
<g className="cursor-pointer" filter="url(#glow)">
|
||||
<rect
|
||||
x="185"
|
||||
y="57"
|
||||
width="28"
|
||||
height="20"
|
||||
rx="6"
|
||||
className="fill-blue-500 dark:fill-blue-600"
|
||||
/>
|
||||
<text
|
||||
x="199"
|
||||
y="70"
|
||||
fontSize="12"
|
||||
fontWeight="bold"
|
||||
className="fill-white"
|
||||
textAnchor="middle"
|
||||
>
|
||||
[1]
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
{/* Connecting line with animation effect */}
|
||||
<g>
|
||||
<path
|
||||
d="M 199 77 Q 240 90, 260 110"
|
||||
className="stroke-blue-500 dark:stroke-blue-400"
|
||||
strokeWidth="2"
|
||||
strokeDasharray="4,4"
|
||||
fill="none"
|
||||
opacity="0.6"
|
||||
>
|
||||
<animate
|
||||
attributeName="stroke-dashoffset"
|
||||
from="8"
|
||||
to="0"
|
||||
dur="1s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</path>
|
||||
|
||||
{/* Arrow head */}
|
||||
<polygon
|
||||
points="258,113 262,110 260,106"
|
||||
className="fill-blue-500 dark:fill-blue-400"
|
||||
opacity="0.6"
|
||||
/>
|
||||
</g>
|
||||
|
||||
{/* Citation popup card */}
|
||||
<g>
|
||||
{/* Card shadow */}
|
||||
<rect
|
||||
x="245"
|
||||
y="113"
|
||||
width="145"
|
||||
height="75"
|
||||
rx="10"
|
||||
className="fill-black"
|
||||
opacity="0.1"
|
||||
transform="translate(2, 2)"
|
||||
/>
|
||||
|
||||
{/* Main card */}
|
||||
<rect
|
||||
x="245"
|
||||
y="113"
|
||||
width="145"
|
||||
height="75"
|
||||
rx="10"
|
||||
className="fill-white dark:fill-neutral-800 stroke-blue-500 dark:stroke-blue-400"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
|
||||
{/* Card header */}
|
||||
<rect
|
||||
x="245"
|
||||
y="113"
|
||||
width="145"
|
||||
height="25"
|
||||
rx="10"
|
||||
className="fill-blue-100 dark:fill-blue-900/50"
|
||||
/>
|
||||
<line
|
||||
x1="245"
|
||||
y1="138"
|
||||
x2="390"
|
||||
y2="138"
|
||||
className="stroke-blue-200 dark:stroke-blue-800"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
|
||||
{/* Header text */}
|
||||
<text
|
||||
x="317.5"
|
||||
y="128"
|
||||
fontSize="9"
|
||||
fontWeight="600"
|
||||
className="fill-blue-700 dark:fill-blue-300"
|
||||
textAnchor="middle"
|
||||
>
|
||||
Referenced Chunk
|
||||
</text>
|
||||
|
||||
{/* Content lines */}
|
||||
<line
|
||||
x1="255"
|
||||
y1="150"
|
||||
x2="365"
|
||||
y2="150"
|
||||
className="stroke-neutral-600 dark:stroke-neutral-400"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<line
|
||||
x1="255"
|
||||
y1="162"
|
||||
x2="340"
|
||||
y2="162"
|
||||
className="stroke-neutral-500 dark:stroke-neutral-500"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<line
|
||||
x1="255"
|
||||
y1="174"
|
||||
x2="380"
|
||||
y2="174"
|
||||
className="stroke-neutral-400 dark:stroke-neutral-600"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</g>
|
||||
|
||||
{/* Sparkle effects */}
|
||||
<g className="opacity-60">
|
||||
{/* Sparkle 1 */}
|
||||
<circle cx="195" cy="45" r="2" className="fill-yellow-400">
|
||||
<animate attributeName="opacity" values="0;1;0" dur="2s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<circle cx="195" cy="45" r="1" className="fill-white">
|
||||
<animate attributeName="opacity" values="0;1;0" dur="2s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
|
||||
{/* Sparkle 2 */}
|
||||
<circle cx="370" cy="125" r="2" className="fill-purple-400">
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0;1;0"
|
||||
dur="2.5s"
|
||||
begin="0.5s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
<circle cx="370" cy="125" r="1" className="fill-white">
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0;1;0"
|
||||
dur="2.5s"
|
||||
begin="0.5s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
|
||||
{/* Sparkle 3 */}
|
||||
<circle cx="250" cy="95" r="1.5" className="fill-blue-400">
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0;1;0"
|
||||
dur="3s"
|
||||
begin="1s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
</g>
|
||||
|
||||
{/* AI Sparkle icon in corner */}
|
||||
<g transform="translate(25, 100)">
|
||||
<path
|
||||
d="M 0,0 L 3,-8 L 6,0 L 14,3 L 6,6 L 3,14 L 0,6 L -8,3 Z"
|
||||
className="fill-purple-500 dark:fill-purple-400"
|
||||
opacity="0.7"
|
||||
>
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="rotate"
|
||||
from="0 3 3"
|
||||
to="360 3 3"
|
||||
dur="8s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</path>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
|
||||
const CollaborationIllustration = () => (
|
||||
<div className="relative flex w-full h-full min-h-44 flex-1 flex-col items-center justify-center overflow-hidden pointer-events-none select-none">
|
||||
<div
|
||||
role="img"
|
||||
aria-label="Illustration of a realtime collaboration in a text editor."
|
||||
className="pointer-events-none absolute inset-0 flex flex-col items-start justify-center pl-4 select-none"
|
||||
>
|
||||
<div className="relative flex h-fit w-fit flex-col items-start">
|
||||
<div className="w-full text-2xl sm:text-3xl lg:text-4xl leading-tight text-neutral-700 dark:text-neutral-300">
|
||||
<span className="flex items-stretch flex-wrap">
|
||||
{/* <span>Real-time </span> */}
|
||||
<span className="relative bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 px-1">
|
||||
Real-time
|
||||
</span>
|
||||
<span className="relative z-10 inline-flex items-stretch justify-start">
|
||||
<span className="absolute h-full w-0.5 rounded-b-sm bg-blue-500"></span>
|
||||
<span className="absolute inline-flex h-6 sm:h-7 -translate-y-full items-center rounded-t-sm rounded-r-sm px-2 py-0.5 text-xs sm:text-sm font-medium text-white bg-blue-500">
|
||||
Sarah
|
||||
</span>
|
||||
</span>
|
||||
<span>collabo</span>
|
||||
<span>orat</span>
|
||||
<span className="relative z-10 inline-flex items-stretch justify-start">
|
||||
<span className="absolute h-full w-0.5 rounded-b-sm bg-purple-600 dark:bg-purple-500"></span>
|
||||
<span className="absolute inline-flex h-6 sm:h-7 -translate-y-full items-center rounded-t-sm rounded-r-sm px-2 py-0.5 text-xs sm:text-sm font-medium text-white bg-purple-600 dark:bg-purple-500">
|
||||
Josh
|
||||
</span>
|
||||
</span>
|
||||
<span>ion</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Bottom gradient fade */}
|
||||
<div className="absolute -right-4 bottom-0 -left-4 h-24 bg-gradient-to-t from-white dark:from-black to-transparent"></div>
|
||||
{/* Right gradient fade */}
|
||||
<div className="absolute top-0 -right-4 bottom-0 w-20 bg-gradient-to-l from-white dark:from-black to-transparent"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const AnnotationIllustration = () => (
|
||||
<div className="relative flex w-full h-full min-h-44 flex-1 flex-col items-center justify-center overflow-hidden pointer-events-none select-none">
|
||||
<div
|
||||
role="img"
|
||||
aria-label="Illustration of a text editor with annotation comments."
|
||||
className="pointer-events-none absolute inset-0 flex flex-col items-start justify-center pl-4 select-none md:left-1/2"
|
||||
>
|
||||
<div className="relative flex h-fit w-fit flex-col items-start justify-center gap-3.5">
|
||||
{/* Text above the comment box */}
|
||||
<div className="absolute left-0 h-fit -translate-x-full pr-7 text-3xl sm:text-4xl lg:text-5xl tracking-tight whitespace-nowrap text-neutral-400 dark:text-neutral-600">
|
||||
<span className="relative">
|
||||
Add context with
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-white dark:from-black via-white dark:via-black to-transparent"></div>
|
||||
</span>{" "}
|
||||
<span className="bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300">
|
||||
comments
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Comment card */}
|
||||
<div className="flex flex-col items-start gap-4 rounded-xl bg-neutral-100 dark:bg-neutral-900/50 px-6 py-5 text-xl sm:text-2xl lg:text-3xl max-w-md">
|
||||
<div className="truncate leading-normal text-neutral-600 dark:text-neutral-400">
|
||||
<span>Let's discuss this tomorrow!</span>
|
||||
</div>
|
||||
|
||||
{/* Reaction icons */}
|
||||
<div className="flex items-center gap-3 opacity-30">
|
||||
{/* @ icon */}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
className="w-6 h-6 sm:w-7 sm:h-7 lg:w-8 lg:h-8"
|
||||
>
|
||||
<title>Mention icon</title>
|
||||
<g
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.8"
|
||||
>
|
||||
<path d="M11.998 15.6a3.6 3.6 0 1 0 0-7.2 3.6 3.6 0 0 0 0 7.2Z" />
|
||||
<path d="M15.602 8.4v4.44c0 1.326 1.026 2.52 2.28 2.52a2.544 2.544 0 0 0 2.52-2.52V12a8.4 8.4 0 1 0-3.36 6.72" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{/* Emoji icon */}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
className="w-6 h-6 sm:w-7 sm:h-7 lg:w-8 lg:h-8"
|
||||
>
|
||||
<title>Emoji icon</title>
|
||||
<g
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.8"
|
||||
>
|
||||
<path d="M12.002 20.4a8.4 8.4 0 1 0 0-16.8 8.4 8.4 0 0 0 0 16.8Z" />
|
||||
<path d="M9 13.8s.9 1.8 3 1.8 3-1.8 3-1.8M9.6 9.6h.008M14.398 9.6h.009M9.597 9.9a.3.3 0 1 0 0-.6.3.3 0 0 0 0 .6ZM14.402 9.9a.3.3 0 1 0 0-.6.3.3 0 0 0 0 .6Z" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{/* Attachment icon */}
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="w-6 h-6 sm:w-7 sm:h-7 lg:w-8 lg:h-8"
|
||||
>
|
||||
<title>Attachment icon</title>
|
||||
<path
|
||||
d="M16.8926 14.0829L12.425 18.4269C10.565 20.2353 7.47136 20.2353 5.61136 18.4269C3.75976 16.6269 3.75976 13.6029 5.61136 11.8029L12.4886 5.11529C13.7294 3.90809 15.7934 3.90689 17.0354 5.11169C18.2714 6.31169 18.2738 8.33009 17.039 9.53249L10.1462 16.2189C9.83929 16.5093 9.43285 16.6711 9.01036 16.6711C8.58786 16.6711 8.18142 16.5093 7.87456 16.2189C7.72623 16.0757 7.60817 15.9042 7.52737 15.7146C7.44656 15.525 7.40466 15.321 7.40416 15.1149C7.40416 14.7009 7.57216 14.3037 7.87456 14.0109L12.4178 9.59849"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom gradient fade */}
|
||||
<div className="absolute -right-4 bottom-0 -left-4 h-20 bg-gradient-to-t from-white dark:from-black to-transparent"></div>
|
||||
{/* Right gradient fade */}
|
||||
<div className="absolute top-0 -right-4 bottom-0 w-20 bg-gradient-to-l from-white dark:from-black to-transparent"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const AudioCommentIllustration = () => (
|
||||
<div className="relative flex w-full h-full min-h-[6rem] overflow-hidden rounded-xl">
|
||||
<Image
|
||||
src="/homepage/comments-audio.webp"
|
||||
alt="Audio Comment Illustration"
|
||||
fill
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const AiSortIllustration = () => (
|
||||
<div className="relative flex w-full h-full min-h-[6rem] items-center justify-center overflow-hidden rounded-xl bg-gradient-to-br from-emerald-50 via-teal-50 to-cyan-50 dark:from-emerald-950/20 dark:via-teal-950/20 dark:to-cyan-950/20 p-4">
|
||||
<svg viewBox="0 0 400 200" className="w-full h-full" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>AI File Sorting illustration showing automatic folder organization</title>
|
||||
{/* Scattered documents on the left */}
|
||||
<g opacity="0.5">
|
||||
<rect
|
||||
x="20"
|
||||
y="40"
|
||||
width="35"
|
||||
height="45"
|
||||
rx="4"
|
||||
className="fill-neutral-200 dark:fill-neutral-700"
|
||||
transform="rotate(-8 37 62)"
|
||||
/>
|
||||
<rect
|
||||
x="50"
|
||||
y="80"
|
||||
width="35"
|
||||
height="45"
|
||||
rx="4"
|
||||
className="fill-neutral-200 dark:fill-neutral-700"
|
||||
transform="rotate(5 67 102)"
|
||||
/>
|
||||
<rect
|
||||
x="15"
|
||||
y="110"
|
||||
width="35"
|
||||
height="45"
|
||||
rx="4"
|
||||
className="fill-neutral-200 dark:fill-neutral-700"
|
||||
transform="rotate(-3 32 132)"
|
||||
/>
|
||||
</g>
|
||||
|
||||
{/* AI sparkle / magic in the center */}
|
||||
<g transform="translate(140, 90)">
|
||||
<path
|
||||
d="M 0,-18 L 4,-6 L 16,-4 L 6,4 L 8,16 L 0,10 L -8,16 L -6,4 L -16,-4 L -4,-6 Z"
|
||||
className="fill-emerald-500 dark:fill-emerald-400"
|
||||
opacity="0.85"
|
||||
>
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="rotate"
|
||||
from="0"
|
||||
to="360"
|
||||
dur="10s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</path>
|
||||
<circle cx="0" cy="0" r="3" className="fill-white dark:fill-emerald-200">
|
||||
<animate attributeName="opacity" values="0.5;1;0.5" dur="2s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
</g>
|
||||
|
||||
{/* Animated sorting arrows */}
|
||||
<g
|
||||
className="stroke-emerald-500 dark:stroke-emerald-400"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
opacity="0.6"
|
||||
>
|
||||
<path d="M 100 70 Q 140 60, 180 50" strokeDasharray="4,4">
|
||||
<animate
|
||||
attributeName="stroke-dashoffset"
|
||||
from="8"
|
||||
to="0"
|
||||
dur="1s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</path>
|
||||
<path d="M 100 100 Q 140 100, 180 100" strokeDasharray="4,4">
|
||||
<animate
|
||||
attributeName="stroke-dashoffset"
|
||||
from="8"
|
||||
to="0"
|
||||
dur="1s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</path>
|
||||
<path d="M 100 130 Q 140 140, 180 150" strokeDasharray="4,4">
|
||||
<animate
|
||||
attributeName="stroke-dashoffset"
|
||||
from="8"
|
||||
to="0"
|
||||
dur="1s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</path>
|
||||
</g>
|
||||
|
||||
{/* Organized folder tree on the right */}
|
||||
{/* Root folder */}
|
||||
<g>
|
||||
<rect
|
||||
x="220"
|
||||
y="30"
|
||||
width="160"
|
||||
height="28"
|
||||
rx="6"
|
||||
className="fill-white dark:fill-neutral-800"
|
||||
opacity="0.9"
|
||||
/>
|
||||
<rect
|
||||
x="228"
|
||||
y="36"
|
||||
width="16"
|
||||
height="14"
|
||||
rx="3"
|
||||
className="fill-emerald-500 dark:fill-emerald-400"
|
||||
/>
|
||||
<line
|
||||
x1="252"
|
||||
y1="43"
|
||||
x2="330"
|
||||
y2="43"
|
||||
className="stroke-neutral-400 dark:stroke-neutral-500"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</g>
|
||||
|
||||
{/* Subfolder 1 */}
|
||||
<g>
|
||||
<line
|
||||
x1="240"
|
||||
y1="58"
|
||||
x2="240"
|
||||
y2="76"
|
||||
className="stroke-neutral-300 dark:stroke-neutral-600"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<line
|
||||
x1="240"
|
||||
y1="76"
|
||||
x2="250"
|
||||
y2="76"
|
||||
className="stroke-neutral-300 dark:stroke-neutral-600"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<rect
|
||||
x="250"
|
||||
y="64"
|
||||
width="130"
|
||||
height="24"
|
||||
rx="5"
|
||||
className="fill-white dark:fill-neutral-800"
|
||||
opacity="0.85"
|
||||
/>
|
||||
<rect
|
||||
x="257"
|
||||
y="70"
|
||||
width="12"
|
||||
height="11"
|
||||
rx="2"
|
||||
className="fill-teal-400 dark:fill-teal-500"
|
||||
/>
|
||||
<line
|
||||
x1="276"
|
||||
y1="76"
|
||||
x2="340"
|
||||
y2="76"
|
||||
className="stroke-neutral-400 dark:stroke-neutral-500"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</g>
|
||||
|
||||
{/* Subfolder 2 */}
|
||||
<g>
|
||||
<line
|
||||
x1="240"
|
||||
y1="76"
|
||||
x2="240"
|
||||
y2="108"
|
||||
className="stroke-neutral-300 dark:stroke-neutral-600"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<line
|
||||
x1="240"
|
||||
y1="108"
|
||||
x2="250"
|
||||
y2="108"
|
||||
className="stroke-neutral-300 dark:stroke-neutral-600"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<rect
|
||||
x="250"
|
||||
y="96"
|
||||
width="130"
|
||||
height="24"
|
||||
rx="5"
|
||||
className="fill-white dark:fill-neutral-800"
|
||||
opacity="0.85"
|
||||
/>
|
||||
<rect
|
||||
x="257"
|
||||
y="102"
|
||||
width="12"
|
||||
height="11"
|
||||
rx="2"
|
||||
className="fill-cyan-400 dark:fill-cyan-500"
|
||||
/>
|
||||
<line
|
||||
x1="276"
|
||||
y1="108"
|
||||
x2="350"
|
||||
y2="108"
|
||||
className="stroke-neutral-400 dark:stroke-neutral-500"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</g>
|
||||
|
||||
{/* Subfolder 3 */}
|
||||
<g>
|
||||
<line
|
||||
x1="240"
|
||||
y1="108"
|
||||
x2="240"
|
||||
y2="140"
|
||||
className="stroke-neutral-300 dark:stroke-neutral-600"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<line
|
||||
x1="240"
|
||||
y1="140"
|
||||
x2="250"
|
||||
y2="140"
|
||||
className="stroke-neutral-300 dark:stroke-neutral-600"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<rect
|
||||
x="250"
|
||||
y="128"
|
||||
width="130"
|
||||
height="24"
|
||||
rx="5"
|
||||
className="fill-white dark:fill-neutral-800"
|
||||
opacity="0.85"
|
||||
/>
|
||||
<rect
|
||||
x="257"
|
||||
y="134"
|
||||
width="12"
|
||||
height="11"
|
||||
rx="2"
|
||||
className="fill-emerald-400 dark:fill-emerald-500"
|
||||
/>
|
||||
<line
|
||||
x1="276"
|
||||
y1="140"
|
||||
x2="325"
|
||||
y2="140"
|
||||
className="stroke-neutral-400 dark:stroke-neutral-500"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</g>
|
||||
|
||||
{/* Sparkle accents */}
|
||||
<g className="opacity-60">
|
||||
<circle cx="170" cy="45" r="2" className="fill-emerald-400">
|
||||
<animate attributeName="opacity" values="0;1;0" dur="2s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<circle cx="190" cy="155" r="1.5" className="fill-teal-400">
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0;1;0"
|
||||
dur="2.5s"
|
||||
begin="0.8s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
<circle cx="155" cy="120" r="1.5" className="fill-cyan-400">
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0;1;0"
|
||||
dur="3s"
|
||||
begin="0.4s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
|
||||
const AutomationIllustration = () => (
|
||||
<div className="relative flex w-full h-full min-h-[6rem] items-center justify-center overflow-hidden rounded-xl bg-gradient-to-br from-indigo-50 via-violet-50 to-fuchsia-50 dark:from-indigo-950/20 dark:via-violet-950/20 dark:to-fuchsia-950/20 p-4">
|
||||
<svg viewBox="0 0 800 200" className="w-full h-full" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>
|
||||
AI automation flow illustration showing a trigger starting an AI agent that acts across
|
||||
connectors
|
||||
</title>
|
||||
|
||||
{/* Animated flow connectors */}
|
||||
<g
|
||||
className="stroke-violet-500 dark:stroke-violet-400"
|
||||
strokeWidth="2.5"
|
||||
fill="none"
|
||||
opacity="0.7"
|
||||
>
|
||||
<path d="M 215 100 L 320 100" strokeDasharray="6,6">
|
||||
<animate
|
||||
attributeName="stroke-dashoffset"
|
||||
from="12"
|
||||
to="0"
|
||||
dur="1s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</path>
|
||||
<path d="M 480 100 L 585 100" strokeDasharray="6,6">
|
||||
<animate
|
||||
attributeName="stroke-dashoffset"
|
||||
from="12"
|
||||
to="0"
|
||||
dur="1s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</path>
|
||||
</g>
|
||||
<g className="fill-violet-500 dark:fill-violet-400" opacity="0.7">
|
||||
<polygon points="320,100 312,95 312,105" />
|
||||
<polygon points="585,100 577,95 577,105" />
|
||||
</g>
|
||||
|
||||
{/* Trigger node */}
|
||||
<g>
|
||||
<rect
|
||||
x="40"
|
||||
y="60"
|
||||
width="175"
|
||||
height="80"
|
||||
rx="14"
|
||||
className="fill-white dark:fill-neutral-800 stroke-indigo-300 dark:stroke-indigo-700"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<text
|
||||
x="127"
|
||||
y="50"
|
||||
fontSize="13"
|
||||
fontWeight="600"
|
||||
className="fill-indigo-600 dark:fill-indigo-300"
|
||||
textAnchor="middle"
|
||||
>
|
||||
Trigger
|
||||
</text>
|
||||
{/* Schedule chip */}
|
||||
<g transform="translate(58, 80)">
|
||||
<rect
|
||||
width="64"
|
||||
height="22"
|
||||
rx="11"
|
||||
className="fill-indigo-100 dark:fill-indigo-900/50"
|
||||
/>
|
||||
<circle
|
||||
cx="14"
|
||||
cy="11"
|
||||
r="6"
|
||||
className="fill-none stroke-indigo-500 dark:stroke-indigo-400"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<line
|
||||
x1="14"
|
||||
y1="11"
|
||||
x2="14"
|
||||
y2="7"
|
||||
className="stroke-indigo-500 dark:stroke-indigo-400"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<line
|
||||
x1="14"
|
||||
y1="11"
|
||||
x2="17"
|
||||
y2="13"
|
||||
className="stroke-indigo-500 dark:stroke-indigo-400"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<text
|
||||
x="38"
|
||||
y="15"
|
||||
fontSize="9"
|
||||
fontWeight="500"
|
||||
className="fill-indigo-700 dark:fill-indigo-300"
|
||||
textAnchor="middle"
|
||||
>
|
||||
Cron
|
||||
</text>
|
||||
</g>
|
||||
{/* Event chip */}
|
||||
<g transform="translate(58, 108)">
|
||||
<rect
|
||||
width="64"
|
||||
height="22"
|
||||
rx="11"
|
||||
className="fill-fuchsia-100 dark:fill-fuchsia-900/40"
|
||||
/>
|
||||
<path
|
||||
d="M 13 5 L 9 13 L 14 13 L 11 19 L 18 10 L 13 10 Z"
|
||||
className="fill-fuchsia-500 dark:fill-fuchsia-400"
|
||||
/>
|
||||
<text
|
||||
x="40"
|
||||
y="15"
|
||||
fontSize="9"
|
||||
fontWeight="500"
|
||||
className="fill-fuchsia-700 dark:fill-fuchsia-300"
|
||||
textAnchor="middle"
|
||||
>
|
||||
Event
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
{/* AI Agent core */}
|
||||
<g>
|
||||
<rect
|
||||
x="320"
|
||||
y="50"
|
||||
width="160"
|
||||
height="100"
|
||||
rx="16"
|
||||
className="fill-white dark:fill-neutral-800 stroke-violet-400 dark:stroke-violet-500"
|
||||
strokeWidth="2.5"
|
||||
/>
|
||||
<text
|
||||
x="400"
|
||||
y="40"
|
||||
fontSize="13"
|
||||
fontWeight="600"
|
||||
className="fill-violet-600 dark:fill-violet-300"
|
||||
textAnchor="middle"
|
||||
>
|
||||
AI Agent
|
||||
</text>
|
||||
{/* Sparkle */}
|
||||
<g transform="translate(400, 92)">
|
||||
<path
|
||||
d="M 0,-22 L 5,-7 L 20,-5 L 7,5 L 10,20 L 0,12 L -10,20 L -7,5 L -20,-5 L -5,-7 Z"
|
||||
className="fill-violet-500 dark:fill-violet-400"
|
||||
opacity="0.9"
|
||||
>
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="rotate"
|
||||
from="0"
|
||||
to="360"
|
||||
dur="12s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</path>
|
||||
<circle cx="0" cy="0" r="4" className="fill-white dark:fill-violet-200">
|
||||
<animate attributeName="opacity" values="0.5;1;0.5" dur="2s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
{/* Actions across connectors */}
|
||||
<g>
|
||||
<rect
|
||||
x="585"
|
||||
y="60"
|
||||
width="175"
|
||||
height="80"
|
||||
rx="14"
|
||||
className="fill-white dark:fill-neutral-800 stroke-fuchsia-300 dark:stroke-fuchsia-700"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<text
|
||||
x="672"
|
||||
y="50"
|
||||
fontSize="13"
|
||||
fontWeight="600"
|
||||
className="fill-fuchsia-600 dark:fill-fuchsia-300"
|
||||
textAnchor="middle"
|
||||
>
|
||||
Act on Connectors
|
||||
</text>
|
||||
<g>
|
||||
<circle cx="618" cy="100" r="13" className="fill-indigo-100 dark:fill-indigo-900/50" />
|
||||
<circle cx="650" cy="100" r="13" className="fill-violet-100 dark:fill-violet-900/50" />
|
||||
<circle cx="682" cy="100" r="13" className="fill-fuchsia-100 dark:fill-fuchsia-900/50" />
|
||||
<circle cx="714" cy="100" r="13" className="fill-pink-100 dark:fill-pink-900/40" />
|
||||
<text
|
||||
x="730"
|
||||
y="104"
|
||||
fontSize="11"
|
||||
fontWeight="600"
|
||||
className="fill-fuchsia-600 dark:fill-fuchsia-300"
|
||||
textAnchor="middle"
|
||||
>
|
||||
25+
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
{/* Sparkle accents */}
|
||||
<g className="opacity-60">
|
||||
<circle cx="270" cy="70" r="2" className="fill-violet-400">
|
||||
<animate attributeName="opacity" values="0;1;0" dur="2s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<circle cx="530" cy="130" r="2" className="fill-fuchsia-400">
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0;1;0"
|
||||
dur="2.5s"
|
||||
begin="0.6s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
|
||||
const items = [
|
||||
{
|
||||
title: "Find, Ask, Act",
|
||||
description:
|
||||
"Get instant information, detailed updates, and cited answers across company and personal knowledge.",
|
||||
header: <CitationIllustration />,
|
||||
className: "md:col-span-2",
|
||||
icon: <IconSearch className="h-4 w-4 text-neutral-500" />,
|
||||
},
|
||||
{
|
||||
title: "Work Together in Real Time",
|
||||
description:
|
||||
"Transform your company docs into multiplayer spaces with live edits, synced content, and presence.",
|
||||
header: <CollaborationIllustration />,
|
||||
className: "md:col-span-1",
|
||||
icon: <IconUsers className="h-4 w-4 text-neutral-500" />,
|
||||
},
|
||||
{
|
||||
title: "AI File Sorting",
|
||||
description:
|
||||
"Automatically organize documents into a smart folder hierarchy using AI-powered categorization by source, date, and topic.",
|
||||
header: <AiSortIllustration />,
|
||||
className: "md:col-span-1",
|
||||
icon: <IconBinaryTree className="h-4 w-4 text-neutral-500" />,
|
||||
},
|
||||
{
|
||||
title: "Collaborate Beyond Text",
|
||||
description:
|
||||
"Create podcasts and multimedia your team can comment on, share, and refine together.",
|
||||
header: <AudioCommentIllustration />,
|
||||
className: "md:col-span-1",
|
||||
icon: <IconMicrophone className="h-4 w-4 text-neutral-500" />,
|
||||
},
|
||||
{
|
||||
title: "Context Where It Counts",
|
||||
description: "Add comments directly to your chats and docs for clear, in-the-moment feedback.",
|
||||
header: <AnnotationIllustration />,
|
||||
className: "md:col-span-1",
|
||||
icon: <IconMessage className="h-4 w-4 text-neutral-500" />,
|
||||
},
|
||||
{
|
||||
title: "Automate Your Workflows",
|
||||
description:
|
||||
"Describe an AI agent in plain English and SurfSense builds it. Run it on a schedule or trigger it when a document lands, acting across all your connectors hands-free.",
|
||||
header: <AutomationIllustration />,
|
||||
className: "md:col-span-3",
|
||||
icon: <IconBolt className="h-4 w-4 text-neutral-500" />,
|
||||
},
|
||||
];
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
import { Sliders, Users, Workflow } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
|
||||
export function FeaturesCards() {
|
||||
return (
|
||||
<section className="py-2 md:py-8 dark:bg-transparent">
|
||||
<div className="@container mx-auto max-w-7xl">
|
||||
<div className="text-center">
|
||||
<h2 className="text-balance text-4xl font-semibold lg:text-5xl">
|
||||
Your Team's AI-Powered Knowledge Hub
|
||||
</h2>
|
||||
<p className="mt-4">
|
||||
Powerful features designed to enhance collaboration, boost productivity, and streamline
|
||||
your workflow.
|
||||
</p>
|
||||
</div>
|
||||
<div className="@min-4xl:max-w-full @min-4xl:grid-cols-3 mx-auto mt-8 grid max-w-sm gap-6 *:text-center md:mt-16">
|
||||
<Card className="group shadow-black-950/5">
|
||||
<CardHeader className="pb-3">
|
||||
<CardDecorator>
|
||||
<Workflow className="size-6" aria-hidden />
|
||||
</CardDecorator>
|
||||
|
||||
<h3 className="mt-6 font-medium">Streamlined Workflow</h3>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<p className="text-sm">
|
||||
Centralize all your knowledge and resources in one intelligent workspace. Find what
|
||||
you need instantly and accelerate decision-making.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="group shadow-black-950/5">
|
||||
<CardHeader className="pb-3">
|
||||
<CardDecorator>
|
||||
<Users className="size-6" aria-hidden />
|
||||
</CardDecorator>
|
||||
|
||||
<h3 className="mt-6 font-medium">Seamless Collaboration</h3>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<p className="text-sm">
|
||||
Work together effortlessly with real-time collaboration tools that keep your entire
|
||||
team aligned.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="group shadow-black-950/5">
|
||||
<CardHeader className="pb-3">
|
||||
<CardDecorator>
|
||||
<Sliders className="size-6" aria-hidden />
|
||||
</CardDecorator>
|
||||
|
||||
<h3 className="mt-6 font-medium">Fully Customizable</h3>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<p className="text-sm">
|
||||
Choose from 100+ leading LLMs, seamlessly calling any model on demand. Even run
|
||||
on-prem local LLM inference via vLLM, Ollama, llama.cpp, LM Studio, and more.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const CardDecorator = ({ children }: { children: ReactNode }) => (
|
||||
<div
|
||||
aria-hidden
|
||||
className="relative mx-auto size-36 mask-[radial-gradient(ellipse_50%_50%_at_50%_50%,#000_70%,transparent_100%)]"
|
||||
>
|
||||
<div className="absolute inset-0 [--border:black] dark:[--border:white] bg-[linear-gradient(to_right,var(--border)_1px,transparent_1px),linear-gradient(to_bottom,var(--border)_1px,transparent_1px)] bg-size-[24px_24px] opacity-10" />
|
||||
<div className="bg-background absolute inset-0 m-auto flex size-12 items-center justify-center border-t border-l">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
69
surfsense_web/components/homepage/flow-line.tsx
Normal file
69
surfsense_web/components/homepage/flow-line.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
"use client";
|
||||
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
|
||||
const EASE_OUT: [number, number, number, number] = [0.16, 1, 0.3, 1];
|
||||
|
||||
/** Column centers for the three equal-width step cards (1/6, 1/2, 5/6). */
|
||||
const NODES = ["16.666%", "50%", "83.333%"];
|
||||
|
||||
/**
|
||||
* Decorative data-flow line tying the three "How it works" steps together.
|
||||
* Sequence on first view: nodes pop in, the line draws left-to-right, then a
|
||||
* repeating dash pattern drifts forever to read as data flowing. Uses only
|
||||
* transform and background-position; renders statically under reduced motion.
|
||||
*/
|
||||
export function FlowLine() {
|
||||
const reduce = useReducedMotion() ?? false;
|
||||
|
||||
const dashes = {
|
||||
backgroundImage: "repeating-linear-gradient(90deg, currentColor 0 6px, transparent 6px 16px)",
|
||||
};
|
||||
|
||||
return (
|
||||
<div aria-hidden className="relative mt-8 mb-4 hidden h-3 text-brand md:block">
|
||||
{/* Line track between the outer nodes */}
|
||||
<div className="absolute top-1/2 left-[16.666%] h-0.5 w-[66.666%] -translate-y-1/2 overflow-hidden rounded-full opacity-70">
|
||||
{reduce ? (
|
||||
<div className="size-full" style={dashes} />
|
||||
) : (
|
||||
<motion.div
|
||||
className="size-full origin-left"
|
||||
initial={{ scaleX: 0 }}
|
||||
whileInView={{ scaleX: 1 }}
|
||||
viewport={{ once: true, amount: 0.5 }}
|
||||
transition={{ duration: 0.7, ease: EASE_OUT, delay: 0.35 }}
|
||||
>
|
||||
<motion.div
|
||||
className="size-full"
|
||||
style={dashes}
|
||||
animate={{ backgroundPositionX: ["0px", "32px"] }}
|
||||
transition={{ duration: 1.6, ease: "linear", repeat: Infinity }}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step nodes at each column center */}
|
||||
{NODES.map((left, i) => (
|
||||
<span
|
||||
key={left}
|
||||
className="absolute top-1/2 -translate-x-1/2 -translate-y-1/2"
|
||||
style={{ left }}
|
||||
>
|
||||
{reduce ? (
|
||||
<span className="block size-3 rounded-full border-2 border-brand bg-background" />
|
||||
) : (
|
||||
<motion.span
|
||||
className="block size-3 rounded-full border-2 border-brand bg-background"
|
||||
initial={{ scale: 0 }}
|
||||
whileInView={{ scale: 1 }}
|
||||
viewport={{ once: true, amount: 0.5 }}
|
||||
transition={{ duration: 0.3, ease: EASE_OUT, delay: 0.1 + i * 0.12 }}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import {
|
|||
IconBrandDiscord,
|
||||
IconBrandGithub,
|
||||
IconBrandLinkedin,
|
||||
IconBrandReddit,
|
||||
IconBrandTwitter,
|
||||
} from "@tabler/icons-react";
|
||||
import Link from "next/link";
|
||||
|
|
@ -21,6 +22,10 @@ export function FooterNew() {
|
|||
// title: "Clients",
|
||||
// href: "#",
|
||||
// },
|
||||
{
|
||||
title: "Connectors",
|
||||
href: "/connectors",
|
||||
},
|
||||
{
|
||||
title: "Pricing",
|
||||
href: "/pricing",
|
||||
|
|
@ -64,6 +69,11 @@ export function FooterNew() {
|
|||
href: "https://discord.gg/ejRNvftDp9",
|
||||
icon: IconBrandDiscord,
|
||||
},
|
||||
{
|
||||
title: "Reddit",
|
||||
href: "https://www.reddit.com/r/SurfSense/",
|
||||
icon: IconBrandReddit,
|
||||
},
|
||||
];
|
||||
const legals = [
|
||||
{
|
||||
|
|
@ -96,7 +106,7 @@ export function FooterNew() {
|
|||
];
|
||||
return (
|
||||
<div className="border-t border-neutral-100 dark:border-white/[0.1] px-8 py-20 bg-white dark:bg-neutral-950 w-full relative overflow-hidden">
|
||||
<div className="max-w-7xl mx-auto text-sm text-neutral-500 flex sm:flex-row flex-col justify-between items-start md:px-8">
|
||||
<div className="max-w-7xl mx-auto text-sm text-neutral-600 dark:text-neutral-400 flex sm:flex-row flex-col justify-between items-start md:px-8">
|
||||
<div>
|
||||
<div className="mr-0 md:mr-4 md:flex mb-4">
|
||||
<Logo className="h-6 w-6 rounded-md mr-2" />
|
||||
|
|
|
|||
363
surfsense_web/components/homepage/hero-chat-demo.tsx
Normal file
363
surfsense_web/components/homepage/hero-chat-demo.tsx
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowUp, ChevronRightIcon, Plus } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type HeroChatDemoStep = {
|
||||
/** Timeline row title, phrased like the real tool display names ("Read webpage"). */
|
||||
title: string;
|
||||
/** Optional sub-bullets shown under the title while/after the step runs. */
|
||||
items?: string[];
|
||||
};
|
||||
|
||||
export type HeroChatDemoScript = {
|
||||
/** The query the typewriter types and "sends". */
|
||||
prompt: string;
|
||||
/** Agent timeline steps, run sequentially like the real chat timeline. */
|
||||
steps: HeroChatDemoStep[];
|
||||
/** Bullet list in the final streamed answer. */
|
||||
rows: { primary: string; secondary: string }[];
|
||||
/** Closing line of the answer. */
|
||||
summary: string;
|
||||
};
|
||||
|
||||
type Stage = "typing" | "steps" | "answer" | "done";
|
||||
|
||||
const PLACEHOLDER =
|
||||
"Research the live web, scrape platforms, automate briefs. Use / for prompts, @ for docs";
|
||||
|
||||
/** Blinking caret for the typewriter (overlay only, never inside the real input). */
|
||||
function Caret() {
|
||||
return (
|
||||
<span className="ml-px inline-block h-4 w-0.5 animate-pulse bg-foreground align-text-bottom" />
|
||||
);
|
||||
}
|
||||
|
||||
/** Status dot cloned from the real timeline: pulsing while running, muted when settled. */
|
||||
function StatusDot({ running }: { running: boolean }) {
|
||||
if (running) {
|
||||
return (
|
||||
<span className="relative flex size-2">
|
||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-primary/60" />
|
||||
<span className="relative inline-flex size-2 rounded-full bg-primary" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span className="size-2 rounded-full bg-muted-foreground/30" />;
|
||||
}
|
||||
|
||||
/** Clone of the chat's collapsible agent timeline (dots, connector lines, shimmer header). */
|
||||
function DemoTimeline({
|
||||
steps,
|
||||
startedCount,
|
||||
runningIndex,
|
||||
settled,
|
||||
}: {
|
||||
steps: HeroChatDemoStep[];
|
||||
startedCount: number;
|
||||
runningIndex: number;
|
||||
settled: boolean;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
|
||||
// Mirror the real timeline: open while processing, auto-collapse once settled.
|
||||
useEffect(() => {
|
||||
setIsOpen(!settled);
|
||||
}, [settled]);
|
||||
|
||||
const visible = steps.slice(0, startedCount);
|
||||
const headerText = settled ? "Reviewed" : (steps[runningIndex]?.title ?? "Processing");
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Button
|
||||
variant="ghost"
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
onClick={() => setIsOpen((prev) => !prev)}
|
||||
className="h-auto w-full justify-start gap-1.5 p-0 text-left text-sm font-normal text-muted-foreground transition-colors hover:bg-transparent hover:text-accent-foreground"
|
||||
>
|
||||
{settled ? <span>{headerText}</span> : <TextShimmerLoader text={headerText} size="sm" />}
|
||||
<ChevronRightIcon
|
||||
className={cn("size-4 transition-transform duration-200", isOpen && "rotate-90")}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"grid transition-[grid-template-rows] duration-300 ease-out",
|
||||
isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="mt-3 pl-1">
|
||||
{visible.map((step, i) => {
|
||||
const running = !settled && i === runningIndex;
|
||||
const isLast = i === visible.length - 1;
|
||||
return (
|
||||
<div key={step.title} className="relative flex gap-3">
|
||||
<div className="relative flex w-2 flex-col items-center self-stretch">
|
||||
{!isLast && (
|
||||
<div className="absolute left-1/2 top-[15px] -bottom-[15px] w-px -translate-x-1/2 bg-muted-foreground/30" />
|
||||
)}
|
||||
<div className="relative z-10 mt-[7px] flex shrink-0 items-center justify-center">
|
||||
<StatusDot running={running} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 pb-4">
|
||||
<div
|
||||
className={cn(
|
||||
"text-sm leading-5",
|
||||
running ? "font-medium text-foreground" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{step.title}
|
||||
</div>
|
||||
{step.items && step.items.length > 0 && (
|
||||
<div className="mt-1 space-y-0.5">
|
||||
{step.items.map((item) => (
|
||||
<div
|
||||
key={item}
|
||||
className="flex items-start gap-1.5 text-xs text-muted-foreground"
|
||||
>
|
||||
<span className="mt-1.5 size-1 shrink-0 rounded-full bg-muted-foreground/40" />
|
||||
<span className="min-w-0">{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scripted clone of the new-chat UI: the demo types a use-case prompt into the
|
||||
* composer, sends it, walks the agent timeline (like the real chat), then
|
||||
* streams the answer. Focusing the input pauses the demo (it resumes on empty
|
||||
* blur); sending anything routes to /login.
|
||||
*/
|
||||
export function HeroChatDemo({
|
||||
demo,
|
||||
reduceMotion,
|
||||
}: {
|
||||
demo: HeroChatDemoScript;
|
||||
reduceMotion: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
const [interrupted, setInterrupted] = useState(false);
|
||||
const [userText, setUserText] = useState("");
|
||||
const [stage, setStage] = useState<Stage>("typing");
|
||||
const [typed, setTyped] = useState("");
|
||||
const [sent, setSent] = useState(false);
|
||||
const [startedSteps, setStartedSteps] = useState(0);
|
||||
const [revealedRows, setRevealedRows] = useState(0);
|
||||
|
||||
const animating = !reduceMotion && !interrupted;
|
||||
|
||||
useEffect(() => {
|
||||
if (!animating) return;
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const wait = (ms: number) =>
|
||||
new Promise<void>((resolve) => {
|
||||
timer = setTimeout(resolve, ms);
|
||||
});
|
||||
|
||||
(async () => {
|
||||
while (!cancelled) {
|
||||
setStage("typing");
|
||||
setTyped("");
|
||||
setSent(false);
|
||||
setStartedSteps(0);
|
||||
setRevealedRows(0);
|
||||
await wait(700);
|
||||
for (let i = 1; i <= demo.prompt.length; i++) {
|
||||
if (cancelled) return;
|
||||
setTyped(demo.prompt.slice(0, i));
|
||||
await wait(20);
|
||||
}
|
||||
await wait(550);
|
||||
if (cancelled) return;
|
||||
setSent(true);
|
||||
setTyped("");
|
||||
setStage("steps");
|
||||
await wait(500);
|
||||
for (let i = 1; i <= demo.steps.length; i++) {
|
||||
if (cancelled) return;
|
||||
setStartedSteps(i);
|
||||
await wait(1400);
|
||||
}
|
||||
if (cancelled) return;
|
||||
setStage("answer");
|
||||
await wait(500); // timeline collapse
|
||||
for (let i = 1; i <= demo.rows.length; i++) {
|
||||
if (cancelled) return;
|
||||
setRevealedRows(i);
|
||||
await wait(500);
|
||||
}
|
||||
await wait(350);
|
||||
setStage("done");
|
||||
await wait(5600);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [animating, demo]);
|
||||
|
||||
// Keep the newest streamed content visible in the small viewport.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: demo progress states are scroll triggers, not effect inputs
|
||||
useEffect(() => {
|
||||
const el = viewportRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [stage, startedSteps, revealedRows, sent]);
|
||||
|
||||
// Reduced motion: show the finished conversation instead of looping.
|
||||
const showSent = reduceMotion ? true : !interrupted && sent;
|
||||
const shownStage: Stage = reduceMotion ? "done" : stage;
|
||||
const shownSteps = reduceMotion ? demo.steps.length : startedSteps;
|
||||
const shownRows = reduceMotion ? demo.rows.length : revealedRows;
|
||||
const stepsSettled = shownStage === "answer" || shownStage === "done";
|
||||
|
||||
const handleSend = () => {
|
||||
router.push("/login");
|
||||
};
|
||||
|
||||
const composer = (
|
||||
<div className="rounded-3xl border border-input/20 bg-muted pt-2 shadow-sm shadow-black/5 transition-[border-color] focus-within:border-input/60 hover:border-input/60">
|
||||
<div className="relative px-4 pt-1 pb-1">
|
||||
<textarea
|
||||
rows={2}
|
||||
value={userText}
|
||||
onChange={(e) => setUserText(e.target.value)}
|
||||
onFocus={() => setInterrupted(true)}
|
||||
onBlur={() => {
|
||||
if (!userText.trim()) setInterrupted(false);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}}
|
||||
placeholder={interrupted ? PLACEHOLDER : undefined}
|
||||
aria-label="Try SurfSense: describe a task for your agent"
|
||||
className="w-full resize-none bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
{!interrupted && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 overflow-hidden px-4 pt-1 pb-1 text-sm"
|
||||
>
|
||||
{typed ? (
|
||||
<span className="text-foreground">
|
||||
{typed}
|
||||
<Caret />
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">{PLACEHOLDER}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Action bar: attach on the left, round send on the right */}
|
||||
<div className="mx-3 mb-3 flex items-center justify-between">
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex size-8 items-center justify-center rounded-full text-muted-foreground"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
onClick={handleSend}
|
||||
aria-label="Send message"
|
||||
className="size-9 shrink-0 rounded-full"
|
||||
>
|
||||
<ArrowUp className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-88 flex-col overflow-hidden rounded-lg border bg-background sm:h-96 sm:rounded-xl">
|
||||
{showSent ? (
|
||||
<>
|
||||
{/* Messages viewport */}
|
||||
<div
|
||||
ref={viewportRef}
|
||||
className="flex-1 overflow-y-auto px-3 py-3 sm:px-4"
|
||||
aria-hidden={!interrupted}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{/* User message (real bubble style) */}
|
||||
<div className="flex justify-end pl-8">
|
||||
<div className="wrap-break-word rounded-xl bg-muted px-4 py-2.5 text-sm text-foreground">
|
||||
{demo.prompt}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent timeline */}
|
||||
{shownSteps > 0 && (
|
||||
<DemoTimeline
|
||||
steps={demo.steps}
|
||||
startedCount={shownSteps}
|
||||
runningIndex={shownSteps - 1}
|
||||
settled={stepsSettled}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Streamed answer */}
|
||||
{stepsSettled && shownRows > 0 && (
|
||||
<div className="space-y-2 pr-6 text-sm leading-relaxed text-foreground">
|
||||
<ul className="space-y-1.5">
|
||||
{demo.rows.slice(0, shownRows).map((row) => (
|
||||
<li
|
||||
key={row.primary}
|
||||
className="fade-in slide-in-from-bottom-1 flex animate-in items-start gap-2 duration-200"
|
||||
>
|
||||
<span className="mt-2 size-1 shrink-0 rounded-full bg-foreground/60" />
|
||||
<span className="min-w-0">
|
||||
<span className="font-medium">{row.primary}</span>
|
||||
<span className="text-muted-foreground"> — {row.secondary}</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{shownStage === "done" && (
|
||||
<p className="fade-in animate-in text-muted-foreground duration-300">
|
||||
{demo.summary}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Composer docked at the bottom while a thread is active */}
|
||||
<div className="p-2 sm:p-3">{composer}</div>
|
||||
</>
|
||||
) : (
|
||||
/* Empty thread: composer centered, like the real new-chat welcome */
|
||||
<div className="flex flex-1 items-center p-3 sm:p-4">
|
||||
<div className="w-full">{composer}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,17 +1,10 @@
|
|||
"use client";
|
||||
import {
|
||||
ChevronDown,
|
||||
Clock,
|
||||
CornerDownLeft,
|
||||
Download,
|
||||
Lightbulb,
|
||||
Monitor,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { ChevronDown, Download } from "lucide-react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import Link from "next/link";
|
||||
import React, { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||
import Balancer from "react-wrap-balancer";
|
||||
import { HeroChatDemo, type HeroChatDemoScript } from "@/components/homepage/hero-chat-demo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -19,19 +12,11 @@ import {
|
|||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from "@/components/ui/empty";
|
||||
import { ExpandedMediaOverlay, useExpandedMedia } from "@/components/ui/expanded-gif-overlay";
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import {
|
||||
GITHUB_RELEASES_URL,
|
||||
getAssetLabel,
|
||||
|
|
@ -74,95 +59,433 @@ type HeroUseCase = {
|
|||
title: string;
|
||||
description: string;
|
||||
src: string | null;
|
||||
comingSoon?: boolean;
|
||||
examples?: string[];
|
||||
/** Scripted chat demo shown when there is no recorded video. */
|
||||
demo?: HeroChatDemoScript;
|
||||
};
|
||||
|
||||
type HeroCategory = {
|
||||
id: string;
|
||||
label: string;
|
||||
desktopOnly?: boolean;
|
||||
useCases: HeroUseCase[];
|
||||
};
|
||||
|
||||
const HERO_TUTORIAL = "/homepage/hero_tutorial";
|
||||
const HERO_REALTIME = "/homepage/hero_realtime";
|
||||
|
||||
/*
|
||||
* Every scripted demo below mirrors a task the SurfSense agent has actually run
|
||||
* end-to-end (see backend agent e2e suite). Recorded videos take precedence via
|
||||
* `src`; everything else plays the chat demo.
|
||||
*/
|
||||
const CATEGORIES: HeroCategory[] = [
|
||||
{
|
||||
id: "desktop",
|
||||
label: "Desktop App",
|
||||
desktopOnly: true,
|
||||
id: "live-research",
|
||||
label: "Live Web Research",
|
||||
useCases: [
|
||||
{
|
||||
id: "general",
|
||||
title: "General Assist",
|
||||
description: "Launch SurfSense instantly from any application with a global shortcut.",
|
||||
src: `${HERO_TUTORIAL}/general_assist.mp4`,
|
||||
id: "deep-research",
|
||||
title: "Deep Research on the Live Web",
|
||||
description:
|
||||
"The agent crawls dozens of live sources on a question and synthesizes a cited answer, not a stale index.",
|
||||
src: null,
|
||||
demo: {
|
||||
prompt: "Research the AI note-taking market and build a landscape brief with citations.",
|
||||
steps: [
|
||||
{
|
||||
title: "Research",
|
||||
items: ["Crawling 38 live sources", "Vendor sites, reviews, pricing pages"],
|
||||
},
|
||||
{
|
||||
title: "Generate report",
|
||||
items: ["Landscape brief · 24 inline citations"],
|
||||
},
|
||||
],
|
||||
rows: [
|
||||
{
|
||||
primary: "23 vendors mapped across 4 segments",
|
||||
secondary: "consumer, prosumer, team, developer-first",
|
||||
},
|
||||
{
|
||||
primary: "Pricing clusters at $10 and $20/mo",
|
||||
secondary: "3 vendors moved upmarket this quarter",
|
||||
},
|
||||
],
|
||||
summary: "Landscape brief saved · 24 inline citations you can check",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "quick",
|
||||
title: "Quick Assist",
|
||||
description: "Select text anywhere, then ask AI to explain, rewrite, or act on it.",
|
||||
src: `${HERO_TUTORIAL}/quick_assist.mp4`,
|
||||
id: "academic-research",
|
||||
title: "Academic Literature Scan",
|
||||
description:
|
||||
"Sweep recent papers, preprints, and technical blogs on a topic and get the main approaches mapped with citations.",
|
||||
src: null,
|
||||
demo: {
|
||||
prompt:
|
||||
"Survey the last year of research on LLM hallucination detection and map the main approaches.",
|
||||
steps: [
|
||||
{
|
||||
title: "Google Search",
|
||||
items: ["12 SERPs · arXiv, ACL, technical blogs"],
|
||||
},
|
||||
{
|
||||
title: "Web Crawler",
|
||||
items: ["Reading 21 papers and posts", "Extracting methods and benchmarks"],
|
||||
},
|
||||
{
|
||||
title: "Generate report",
|
||||
items: ["Literature brief · grouped by approach"],
|
||||
},
|
||||
],
|
||||
rows: [
|
||||
{
|
||||
primary: "4 approach families mapped",
|
||||
secondary: "self-consistency, retrieval grounding, probes, uncertainty",
|
||||
},
|
||||
{
|
||||
primary: "Benchmarks converge on 2 suites",
|
||||
secondary: "used in 9 of the 21 papers reviewed",
|
||||
},
|
||||
],
|
||||
summary: "Literature brief saved · every claim links to its paper",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "screenshot",
|
||||
title: "Screenshot Assist",
|
||||
description: "Capture any region of your screen and ask AI about what’s in it.",
|
||||
src: `${HERO_TUTORIAL}/screenshot_assist.mp4`,
|
||||
id: "financial-research",
|
||||
title: "Financial & Market Research",
|
||||
description:
|
||||
"Pull earnings coverage, analyst breakdowns, and retail sentiment on any company into one cited brief.",
|
||||
src: null,
|
||||
demo: {
|
||||
prompt:
|
||||
"Summarize the reaction to NVIDIA's latest earnings across news, YouTube, and Reddit.",
|
||||
steps: [
|
||||
{
|
||||
title: "Google Search",
|
||||
items: ["10 SERPs · earnings coverage and recaps"],
|
||||
},
|
||||
{
|
||||
title: "Youtube",
|
||||
items: ["8 analyst breakdowns · transcripts pulled"],
|
||||
},
|
||||
{
|
||||
title: "Reddit",
|
||||
items: ["r/investing + r/stocks · 31 threads"],
|
||||
},
|
||||
],
|
||||
rows: [
|
||||
{
|
||||
primary: "Data-center revenue beat leads coverage",
|
||||
secondary: "cited in 7 of 10 top results",
|
||||
},
|
||||
{
|
||||
primary: "Analyst take split on guidance",
|
||||
secondary: "transcripts: 5 bullish · 3 cautious",
|
||||
},
|
||||
{
|
||||
primary: "Retail sentiment net positive",
|
||||
secondary: "top threads focus on supply constraints",
|
||||
},
|
||||
],
|
||||
summary: "Cited earnings brief saved to your workspace",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "watch-folder",
|
||||
title: "Watch Local Folder",
|
||||
description: "Auto-sync a local folder to your knowledge base. Great for Obsidian vaults.",
|
||||
src: `${HERO_TUTORIAL}/folder_watch.mp4`,
|
||||
id: "geo-monitoring",
|
||||
title: "AI Overview & GEO Tracking",
|
||||
description:
|
||||
"Capture when Google's AI Overviews answer the queries you care about, and exactly which sources they cite.",
|
||||
src: null,
|
||||
demo: {
|
||||
prompt: "Which of our target keywords trigger an AI Overview, and who gets cited?",
|
||||
steps: [
|
||||
{
|
||||
title: "Google Search",
|
||||
items: ["Scraping 25 SERPs", "Capturing AI Overviews and citations"],
|
||||
},
|
||||
{
|
||||
title: "Plan tasks",
|
||||
items: ["Map citations to competitors", "Compute your citation gap"],
|
||||
},
|
||||
],
|
||||
rows: [
|
||||
{
|
||||
primary: "9 of 25 keywords trigger an AI Overview",
|
||||
secondary: "up from 6 last month",
|
||||
},
|
||||
{
|
||||
primary: "Competitor A cited on 4 · you on 1",
|
||||
secondary: "their listicle wins 3 of those citations",
|
||||
},
|
||||
],
|
||||
summary: "Citation gap report saved · weekly re-check scheduled",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "deliverables",
|
||||
label: "Deliverable Studio",
|
||||
id: "ci-workflows",
|
||||
label: "Competitive Intelligence Workflows",
|
||||
useCases: [
|
||||
{
|
||||
id: "launch-impact",
|
||||
title: "Launch Impact, Across Every Platform",
|
||||
description:
|
||||
"One prompt chains Google Search, Reddit, and YouTube into a single cited brief on how a competitor launch actually landed.",
|
||||
src: null,
|
||||
demo: {
|
||||
prompt:
|
||||
"Our competitor launched v2 yesterday. Measure the reaction across search, Reddit, and YouTube.",
|
||||
steps: [
|
||||
{
|
||||
title: "Google Search",
|
||||
items: ["Scraping 8 SERPs · launch coverage + AI Overviews"],
|
||||
},
|
||||
{
|
||||
title: "Reddit",
|
||||
items: ['"competitor v2" · 23 threads in the past 48h'],
|
||||
},
|
||||
{
|
||||
title: "Youtube",
|
||||
items: ["6 launch videos · 1,904 comments pulled"],
|
||||
},
|
||||
{
|
||||
title: "Plan tasks",
|
||||
items: ["Merge all three signals into one launch-impact brief"],
|
||||
},
|
||||
],
|
||||
rows: [
|
||||
{
|
||||
primary: "5 of 8 SERPs show launch coverage",
|
||||
secondary: "2 already trigger AI Overviews citing their blog",
|
||||
},
|
||||
{
|
||||
primary: "Reddit: pricing backlash in 9 of 23 threads",
|
||||
secondary: '"v2 doubled the price" · top thread 412 upvotes',
|
||||
},
|
||||
{
|
||||
primary: "YouTube: creators praise UI, question pricing",
|
||||
secondary: "61% positive on features · pricing the top complaint",
|
||||
},
|
||||
],
|
||||
summary: "3 connectors, one cited brief · saved to your workspace",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "local-teardown",
|
||||
title: "Local Competitor Teardown",
|
||||
description:
|
||||
"Google Maps finds the players, the Web Crawler reads their sites, and Google Search shows who wins the query, in one run.",
|
||||
src: null,
|
||||
demo: {
|
||||
prompt:
|
||||
'Tear down the top-rated gyms in Austin: reviews, pricing pages, and who ranks for "gym austin".',
|
||||
steps: [
|
||||
{
|
||||
title: "Google Maps",
|
||||
items: ['"gym austin" · top 10 places + 2,400 reviews'],
|
||||
},
|
||||
{
|
||||
title: "Web Crawler",
|
||||
items: ["Visiting 10 gym sites", "Extracting pricing and membership pages"],
|
||||
},
|
||||
{
|
||||
title: "Google Search",
|
||||
items: ['SERP for "gym austin" · organic, ads, map pack'],
|
||||
},
|
||||
],
|
||||
rows: [
|
||||
{
|
||||
primary: "Review themes: crowding + billing complaints",
|
||||
secondary: "appear in 31% of 1-3★ reviews across 10 gyms",
|
||||
},
|
||||
{
|
||||
primary: "Pricing: $89–149/mo · 3 hide it behind forms",
|
||||
secondary: "extracted from all 10 sites with source pages",
|
||||
},
|
||||
{
|
||||
primary: "2 gyms buy ads on their own brand name",
|
||||
secondary: "map pack and organic top 3 don't overlap",
|
||||
},
|
||||
],
|
||||
summary: "Maps + Crawler + Search in one run · teardown saved",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "pricing-watch",
|
||||
title: "Competitor Pricing Watch",
|
||||
description:
|
||||
"The agent extracts every plan from a competitor's pricing page, and an automation re-checks it so you hear about changes first.",
|
||||
src: null,
|
||||
demo: {
|
||||
prompt: "Extract every plan, price, and limit from our top 3 competitors' pricing pages.",
|
||||
steps: [
|
||||
{
|
||||
title: "Plan tasks",
|
||||
items: ["Crawl 3 pricing pages", "Extract plans, prices, limits into one table"],
|
||||
},
|
||||
{
|
||||
title: "Web Crawler",
|
||||
items: [
|
||||
"competitor-a.com/pricing · 4 plans",
|
||||
"competitor-b.com/pricing · 3 plans",
|
||||
"competitor-c.com/plans · 4 plans",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Create automation",
|
||||
items: ["Daily pricing re-check · alert on any change"],
|
||||
},
|
||||
],
|
||||
rows: [
|
||||
{
|
||||
primary: "Competitor A — Pro $49/mo",
|
||||
secondary: "10k credits · 3 seats · raised from $39 on Jun 12",
|
||||
},
|
||||
{
|
||||
primary: "Competitor B — Team $99/mo",
|
||||
secondary: "50k credits · unlimited seats · annual-only",
|
||||
},
|
||||
{
|
||||
primary: "Competitor C — Free tier removed",
|
||||
secondary: "Trial now 7 days · card required",
|
||||
},
|
||||
],
|
||||
summary: "3 pages parsed · 11 plans in one table · daily re-check scheduled",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "site-diff",
|
||||
title: "Product & Changelog Tracking",
|
||||
description:
|
||||
"An automation crawls a rival's product, changelog, and careers pages and briefs you on what shipped.",
|
||||
src: null,
|
||||
demo: {
|
||||
prompt:
|
||||
"Every Monday, crawl our competitors' changelogs and brief me on what they shipped.",
|
||||
steps: [
|
||||
{
|
||||
title: "Web Crawler",
|
||||
items: [
|
||||
"competitor-a.com/changelog · 6 entries",
|
||||
"competitor-b.com/whats-new · 3 entries",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Create automation",
|
||||
items: ["Weekly changelog brief · Mondays 8:00"],
|
||||
},
|
||||
],
|
||||
rows: [
|
||||
{
|
||||
primary: "Competitor A shipped SSO + audit logs",
|
||||
secondary: "changelog · Jun 30 · enterprise push",
|
||||
},
|
||||
{
|
||||
primary: "Competitor B launched API v2 beta",
|
||||
secondary: "whats-new · Jul 2 · targets developers",
|
||||
},
|
||||
],
|
||||
summary: "Brief saved to workspace · automation runs Mondays 8:00",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "serp-watch",
|
||||
title: "Rank & Ad Monitoring",
|
||||
description:
|
||||
"Automations track the Google rankings, paid ads, and AI Overview citations your audience actually sees.",
|
||||
src: null,
|
||||
demo: {
|
||||
prompt: "Track who ranks and runs ads for our top 10 keywords in the US.",
|
||||
steps: [
|
||||
{
|
||||
title: "Google Search",
|
||||
items: ["Scraping 10 SERPs (US) · organic, ads, AI Overviews"],
|
||||
},
|
||||
{
|
||||
title: "Plan tasks",
|
||||
items: ["Diff against last capture", "Flag rank and ad movements"],
|
||||
},
|
||||
{
|
||||
title: "Create automation",
|
||||
items: ["Daily rank + ad watch on these keywords"],
|
||||
},
|
||||
],
|
||||
rows: [
|
||||
{
|
||||
primary: '"ai research tools" — you #4, ↓1',
|
||||
secondary: "Competitor A took #3 · runs 2 sponsored ads",
|
||||
},
|
||||
{
|
||||
primary: "AI Overview cites Competitor B",
|
||||
secondary: 'triggered on "brand monitoring software"',
|
||||
},
|
||||
],
|
||||
summary: "10 SERPs captured · 3 movements flagged · daily automation on",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "switcher-mining",
|
||||
title: "Switcher & Intent Mining",
|
||||
description:
|
||||
"Find the people actively looking for an alternative to a competitor, ranked by how ready they are to move.",
|
||||
src: null,
|
||||
demo: {
|
||||
prompt: "Find people asking for alternatives to our biggest competitor this month.",
|
||||
steps: [
|
||||
{
|
||||
title: "Reddit",
|
||||
items: [
|
||||
'Searching "alternative" mentions · past month',
|
||||
"12 active switcher threads",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Plan tasks",
|
||||
items: ["Rank by recency and engagement", "Extract switching triggers"],
|
||||
},
|
||||
],
|
||||
rows: [
|
||||
{
|
||||
primary: "12 threads with active switchers",
|
||||
secondary: "ranked by recency and engagement",
|
||||
},
|
||||
{
|
||||
primary: "Top trigger: API price increase",
|
||||
secondary: "mentioned in 7 of 12 threads",
|
||||
},
|
||||
],
|
||||
summary: "Outreach-ready summaries drafted for the 5 hottest threads",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "artifacts",
|
||||
label: "Artifacts (Podcasts, Videos & More)",
|
||||
useCases: [
|
||||
{
|
||||
id: "report",
|
||||
title: "AI Report Generator",
|
||||
description:
|
||||
"Generate cited research reports from your documents, then export to PDF or Markdown.",
|
||||
description: "Turn your research into cited reports, then export to PDF or Markdown.",
|
||||
src: `${HERO_TUTORIAL}/ReportGenGif_compressed.mp4`,
|
||||
},
|
||||
{
|
||||
id: "podcast",
|
||||
title: "AI Podcast Generator",
|
||||
description: "Turn any document or folder into a two-host AI podcast in under 20 seconds.",
|
||||
description: "Turn any brief or folder into a two-host AI podcast in under 20 seconds.",
|
||||
src: `${HERO_TUTORIAL}/PodcastGenGif.mp4`,
|
||||
},
|
||||
{
|
||||
id: "presentation",
|
||||
title: "AI Presentation & Video Maker",
|
||||
description: "Create editable slide decks and narrated video overviews from your sources.",
|
||||
description: "Create editable slide decks and narrated video overviews from your findings.",
|
||||
src: `${HERO_TUTORIAL}/video_gen_surf.mp4`,
|
||||
},
|
||||
{
|
||||
id: "image",
|
||||
title: "AI Image Generator",
|
||||
description: "Generate high-quality images straight from your chats and documents.",
|
||||
id: "image-gen",
|
||||
title: "AI Image Generation",
|
||||
description: "Generate images inside your workspace for decks, briefs, and posts.",
|
||||
src: `${HERO_TUTORIAL}/ImageGenGif.mp4`,
|
||||
},
|
||||
{
|
||||
id: "resume",
|
||||
title: "AI Resume Builder",
|
||||
description: "Tailor your existing resume to any job description and beat the ATS.",
|
||||
src: null,
|
||||
comingSoon: true,
|
||||
examples: [
|
||||
"Tailor my resume to this job description so it gets past ATS and lands an interview.",
|
||||
"Optimize my resume for ATS by matching the keywords in this job posting.",
|
||||
"Rewrite my resume bullet points to highlight the skills this role is asking for.",
|
||||
"Compare my resume against this job description and list the gaps to fix.",
|
||||
"Write a matching cover letter from my resume and this job description.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
@ -170,110 +493,146 @@ const CATEGORIES: HeroCategory[] = [
|
|||
label: "Automations",
|
||||
useCases: [
|
||||
{
|
||||
id: "schedule",
|
||||
title: "Scheduled AI Workflows",
|
||||
description: "Run an agent on a schedule: daily briefs, weekly digests, recurring reports.",
|
||||
src: null,
|
||||
comingSoon: true,
|
||||
examples: [
|
||||
"Email me a daily brief of new documents in my knowledge base every morning.",
|
||||
"Generate a weekly status report from my Slack and Gmail every Friday.",
|
||||
"Run a monthly competitor analysis report and save it to my workspace.",
|
||||
"Summarize my GitHub and Linear activity into a daily standup update.",
|
||||
"Create a recurring weekly research report on the topics I track.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "event",
|
||||
title: "Event-Triggered Automations",
|
||||
id: "competitor-360",
|
||||
title: "Competitor 360, on a Schedule",
|
||||
description:
|
||||
"Fire an agent the moment a document lands in a folder, then post the result to your tools.",
|
||||
"An automation chains four connectors every week: site changes, rank movements, Reddit sentiment, and YouTube reaction.",
|
||||
src: null,
|
||||
comingSoon: true,
|
||||
examples: [
|
||||
"When a PDF lands in my Research folder, generate a cited AI summary.",
|
||||
"When new meeting notes are added, turn them into meeting minutes with action items.",
|
||||
"When an invoice is uploaded, extract the vendor, total, and due date into a table.",
|
||||
"When a contract enters my Legal folder, flag key terms and renewal dates.",
|
||||
"When a resume is added to Candidates, screen it against the job description.",
|
||||
],
|
||||
demo: {
|
||||
prompt:
|
||||
"Every Monday, build me a 360 on our top competitor: site changes, rankings, Reddit, and YouTube.",
|
||||
steps: [
|
||||
{
|
||||
title: "Web Crawler",
|
||||
items: ["pricing + changelog pages · 2 changes detected"],
|
||||
},
|
||||
{
|
||||
title: "Google Search",
|
||||
items: ["12 shared keywords · rank movements captured"],
|
||||
},
|
||||
{
|
||||
title: "Reddit",
|
||||
items: ["18 mentions this week · sentiment tagged"],
|
||||
},
|
||||
{
|
||||
title: "Youtube",
|
||||
items: ["2 new videos · comments and transcripts pulled"],
|
||||
},
|
||||
{
|
||||
title: "Create automation",
|
||||
items: ["Weekly 360 brief · Mondays 8:00"],
|
||||
},
|
||||
],
|
||||
rows: [
|
||||
{
|
||||
primary: "Shipped: usage-based pricing page",
|
||||
secondary: "pricing + changelog diff · detected Jul 3",
|
||||
},
|
||||
{
|
||||
primary: 'Took #2 on "reddit scraper api"',
|
||||
secondary: "you hold #4 · gap widened two weeks in a row",
|
||||
},
|
||||
{
|
||||
primary: "Reddit sentiment down 12 pts since the change",
|
||||
secondary: "churn signals in 5 threads · quotes linked",
|
||||
},
|
||||
],
|
||||
summary: "4 connectors, 1 automation · first brief lands Monday 8:00",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "chat-built",
|
||||
title: "Chat-Built Automations",
|
||||
description: "Describe an automation in plain English and SurfSense builds it for you.",
|
||||
id: "cited-briefs",
|
||||
title: "Scheduled Briefs & Alerts",
|
||||
description:
|
||||
"Everything the agents gather lands in your workspace as briefs and alerts with sources you can check.",
|
||||
src: null,
|
||||
comingSoon: true,
|
||||
examples: [
|
||||
"Build an AI agent that emails me a summary of new Notion pages each morning.",
|
||||
"Create a no-code automation that posts a weekly research digest to Slack.",
|
||||
"Set up an AI note taker that turns new meeting notes into minutes.",
|
||||
"Make a workflow that extracts action items from meeting notes and assigns owners.",
|
||||
"Automate a daily email brief from my Gmail and Google Drive.",
|
||||
],
|
||||
demo: {
|
||||
prompt: "Send me a Monday brief of every change my agents detected last week.",
|
||||
steps: [
|
||||
{
|
||||
title: "Plan tasks",
|
||||
items: ["Collect pricing, changelog, SERP, Reddit signals"],
|
||||
},
|
||||
{
|
||||
title: "Create automation",
|
||||
items: ["Weekly brief · Mondays 8:00 · workspace + email"],
|
||||
},
|
||||
],
|
||||
rows: [
|
||||
{
|
||||
primary: "Sources: pricing, changelogs, SERPs, Reddit",
|
||||
secondary: "everything your agents tracked this week",
|
||||
},
|
||||
{
|
||||
primary: "Delivered to workspace + email",
|
||||
secondary: "every claim links to its source",
|
||||
},
|
||||
],
|
||||
summary: "Automation created · first brief lands Monday 8:00",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "event-triggers",
|
||||
title: "Event-Triggered Workflows",
|
||||
description:
|
||||
"Automations can fire on events, not just schedules: a document landing in a folder kicks off the workflow.",
|
||||
src: null,
|
||||
demo: {
|
||||
prompt:
|
||||
"Whenever a new file lands in my Research folder, summarize it and post the summary to Slack.",
|
||||
steps: [
|
||||
{
|
||||
title: "Create automation",
|
||||
items: [
|
||||
"Trigger: new document in Research folder",
|
||||
"Action: summarize → post to #research",
|
||||
],
|
||||
},
|
||||
],
|
||||
rows: [
|
||||
{
|
||||
primary: "Automation armed on the Research folder",
|
||||
secondary: "fires the moment a document lands",
|
||||
},
|
||||
{
|
||||
primary: "First run: competitor-teardown.pdf",
|
||||
secondary: "summary posted to #research · 42s after upload",
|
||||
},
|
||||
],
|
||||
summary: "Event-triggered automation live · no schedule needed",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "search-chat",
|
||||
label: "Search & Chat",
|
||||
id: "desktop-app",
|
||||
label: "Desktop App",
|
||||
useCases: [
|
||||
{
|
||||
id: "chat-docs",
|
||||
title: "Chat With Your PDFs & Docs",
|
||||
description: "Ask questions across all your files and get answers with inline citations.",
|
||||
src: `${HERO_TUTORIAL}/BQnaGif_compressed.mp4`,
|
||||
},
|
||||
{
|
||||
id: "search",
|
||||
title: "AI Search With Citations",
|
||||
description: "Hybrid semantic and keyword search across your entire knowledge base.",
|
||||
src: `${HERO_TUTORIAL}/BSNCGif.mp4`,
|
||||
},
|
||||
{
|
||||
id: "collab",
|
||||
title: "Collaborative AI Chat",
|
||||
description: "Work on AI conversations with your team in real time.",
|
||||
src: `${HERO_REALTIME}/RealTimeChatGif.mp4`,
|
||||
},
|
||||
{
|
||||
id: "comments",
|
||||
title: "Comments & Mentions",
|
||||
description: "Comment and tag teammates on any AI message.",
|
||||
src: `${HERO_REALTIME}/RealTimeCommentsFlow.mp4`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "connectors",
|
||||
label: "Connectors & Integrations",
|
||||
useCases: [
|
||||
{
|
||||
id: "connect",
|
||||
title: "Connect & Sync Your Tools",
|
||||
id: "general-assist",
|
||||
title: "General Assist",
|
||||
description:
|
||||
"Sync Notion, Slack, Google Drive, Gmail, GitHub, Linear and 25+ sources into one searchable corpus.",
|
||||
src: `${HERO_TUTORIAL}/ConnectorFlowGif.mp4`,
|
||||
"Launch SurfSense from any application on your computer with a global shortcut.",
|
||||
src: `${HERO_TUTORIAL}/general_assist.mp4`,
|
||||
},
|
||||
{
|
||||
id: "upload",
|
||||
title: "Chat With Uploaded Files",
|
||||
description: "Drop in PDFs, Office docs, images and audio. Instantly searchable.",
|
||||
src: `${HERO_TUTORIAL}/DocUploadGif.mp4`,
|
||||
id: "quick-assist",
|
||||
title: "Quick Assist",
|
||||
description: "Select text anywhere, then ask AI to explain, rewrite, or act on it.",
|
||||
src: `${HERO_TUTORIAL}/quick_assist.mp4`,
|
||||
},
|
||||
{
|
||||
id: "write-back",
|
||||
title: "Connector Write-Back",
|
||||
description: "Let the agent post results back to Notion, Slack, Linear and Drive.",
|
||||
src: null,
|
||||
comingSoon: true,
|
||||
examples: [
|
||||
"Post this research summary to my Notion workspace.",
|
||||
"Send these meeting action items to our team Slack channel.",
|
||||
"Create a Jira ticket from this bug report.",
|
||||
"Open a Linear issue from this feature request.",
|
||||
"Save this generated report to Google Drive as a doc.",
|
||||
],
|
||||
id: "screenshot-assist",
|
||||
title: "Screenshot Assist",
|
||||
description: "Capture any region of your screen and ask AI about it.",
|
||||
src: `${HERO_TUTORIAL}/screenshot_assist.mp4`,
|
||||
},
|
||||
{
|
||||
id: "folder-watch",
|
||||
title: "Watch Local Folder",
|
||||
description:
|
||||
"Auto-sync a local folder to your knowledge base. Point it at your Obsidian vault to keep your notes searchable.",
|
||||
src: `${HERO_TUTORIAL}/folder_watch.mp4`,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -285,25 +644,27 @@ export function HeroSection() {
|
|||
<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"
|
||||
"relative mt-4 max-w-4xl text-left text-4xl font-bold tracking-tight text-balance text-neutral-900 sm:text-5xl md:text-6xl dark:text-neutral-50"
|
||||
)}
|
||||
>
|
||||
<Balancer>NotebookLM for Teams</Balancer>
|
||||
<Balancer>The open-source NotebookLM alternative for open web research.</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 className="mt-4 flex w-full flex-col items-start justify-between gap-4 md:mt-8 md:flex-row md:items-end md:gap-10">
|
||||
<div>
|
||||
<p
|
||||
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"
|
||||
"relative mb-8 max-w-2xl text-left text-sm text-neutral-600 antialiased sm:text-base md:text-lg dark:text-neutral-400"
|
||||
)}
|
||||
>
|
||||
A free, open source NotebookLM alternative for teams with no data limits. Use ChatGPT,
|
||||
Claude AI, and any AI model for free.
|
||||
SurfSense is the open-source NotebookLM alternative for AI agents, an open web
|
||||
research platform with live data connectors. Your AI agents research the live web with
|
||||
structured data from Reddit, YouTube, Instagram, TikTok, Amazon, Walmart, Google
|
||||
Maps, Google Search, and any page on the open web.
|
||||
</p>
|
||||
|
||||
<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">
|
||||
<DownloadButton />
|
||||
<GetStartedButton />
|
||||
<DownloadButton />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -398,9 +759,10 @@ function DownloadButton() {
|
|||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
aria-label="More download options"
|
||||
className="h-auto rounded-l-none rounded-r-lg border border-neutral-200 bg-white px-2.5 text-neutral-500 shadow-sm transition duration-150 hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-400 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<ChevronDown className="size-4" />
|
||||
<ChevronDown className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64">
|
||||
|
|
@ -478,52 +840,6 @@ const TabVideo = memo(function TabVideo({
|
|||
);
|
||||
});
|
||||
|
||||
const UseCasePlaceholder = ({ title }: { title: string }) => (
|
||||
<Empty className="size-full justify-center rounded-lg border border-dashed bg-muted/30 sm:rounded-xl">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<Clock aria-hidden="true" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>Demo coming soon</EmptyTitle>
|
||||
<EmptyDescription className="text-pretty">{`A walkthrough of ${title} is on the way.`}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
);
|
||||
|
||||
const UseCaseExamples = ({ examples }: { examples: string[] }) => (
|
||||
<div className="flex size-full flex-col gap-3 rounded-lg border border-dashed bg-muted/30 p-4 sm:rounded-xl sm:p-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Lightbulb aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-foreground">Try prompts like these today</p>
|
||||
</div>
|
||||
<ul className="flex min-w-0 flex-col gap-2">
|
||||
{examples.map((example) => (
|
||||
<li key={example}>
|
||||
<div className="flex items-start gap-2.5 rounded-md border bg-background px-3 py-2">
|
||||
<CornerDownLeft
|
||||
aria-hidden="true"
|
||||
className="mt-0.5 size-3.5 shrink-0 text-muted-foreground/70"
|
||||
/>
|
||||
<span className="min-w-0 text-sm text-pretty text-muted-foreground">{example}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
|
||||
const DesktopBadge = () => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="ml-0.5 inline-flex items-center text-amber-600 dark:text-amber-400">
|
||||
<Monitor aria-hidden="true" className="size-3.5" />
|
||||
<span className="sr-only">Desktop app only</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Desktop app only</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const UseCasePane = memo(function UseCasePane({
|
||||
useCase,
|
||||
reduceMotion,
|
||||
|
|
@ -532,7 +848,7 @@ const UseCasePane = memo(function UseCasePane({
|
|||
reduceMotion: boolean;
|
||||
}) {
|
||||
const { expanded, open, close } = useExpandedMedia();
|
||||
const hasVideo = !useCase.comingSoon && Boolean(useCase.src);
|
||||
const hasVideo = Boolean(useCase.src);
|
||||
|
||||
const media = hasVideo ? (
|
||||
<Button
|
||||
|
|
@ -546,13 +862,7 @@ const UseCasePane = memo(function UseCasePane({
|
|||
</Button>
|
||||
) : (
|
||||
<div className="bg-neutral-50 p-2 sm:p-3 dark:bg-neutral-950">
|
||||
{useCase.examples && useCase.examples.length > 0 ? (
|
||||
<UseCaseExamples examples={useCase.examples} />
|
||||
) : (
|
||||
<div className="aspect-video w-full">
|
||||
<UseCasePlaceholder title={useCase.title} />
|
||||
</div>
|
||||
)}
|
||||
{useCase.demo && <HeroChatDemo demo={useCase.demo} reduceMotion={reduceMotion} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
|
@ -560,9 +870,9 @@ const UseCasePane = memo(function UseCasePane({
|
|||
<div className="relative overflow-hidden rounded-tl-xl rounded-tr-xl bg-white shadow-sm ring-1 shadow-black/10 ring-black/10 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">
|
||||
<h2 className="truncate text-base font-semibold text-neutral-900 sm:text-lg dark:text-white">
|
||||
{useCase.title}
|
||||
</h3>
|
||||
</h2>
|
||||
<p className="text-sm text-neutral-500 text-pretty dark:text-neutral-400">
|
||||
{useCase.description}
|
||||
</p>
|
||||
|
|
@ -609,14 +919,6 @@ const CategoryPanel = memo(function CategoryPanel({
|
|||
}) {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
{category.desktopOnly && (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-amber-300/60 bg-amber-50 px-3 py-2 text-xs text-amber-800 sm:text-sm dark:border-amber-500/40 dark:bg-amber-950/30 dark:text-amber-200">
|
||||
<Sparkles aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
|
||||
<span className="text-pretty">
|
||||
The desktop app includes everything in SurfSense, plus these native-only superpowers.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Tabs
|
||||
defaultValue={category.useCases[0]?.id}
|
||||
orientation="vertical"
|
||||
|
|
@ -670,15 +972,9 @@ const BrowserWindow = () => {
|
|||
<React.Fragment key={category.id}>
|
||||
<TabsTrigger
|
||||
value={category.id}
|
||||
className={cn(
|
||||
"h-auto shrink-0 touch-manipulation gap-1.5 rounded-md px-2.5 py-1 text-xs sm:text-sm",
|
||||
category.desktopOnly
|
||||
? "bg-amber-100/70 text-amber-800 hover:bg-amber-100 data-[state=active]:bg-amber-200/80 data-[state=active]:text-amber-900 data-[state=active]:shadow-sm dark:bg-amber-950/40 dark:text-amber-200 dark:hover:bg-amber-900/40 dark:data-[state=active]:bg-amber-900/60 dark:data-[state=active]:text-amber-50"
|
||||
: "data-[state=active]:bg-background data-[state=active]:shadow"
|
||||
)}
|
||||
className="h-auto shrink-0 touch-manipulation gap-1.5 rounded-md px-2.5 py-1 text-xs data-[state=active]:bg-background data-[state=active]:shadow sm:text-sm"
|
||||
>
|
||||
{category.label}
|
||||
{category.desktopOnly && <DesktopBadge />}
|
||||
</TabsTrigger>
|
||||
{index !== CATEGORIES.length - 1 && (
|
||||
<Separator
|
||||
|
|
|
|||
52
surfsense_web/components/homepage/home-faq.tsx
Normal file
52
surfsense_web/components/homepage/home-faq.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { ConnectorFaq } from "@/components/connectors-marketing/connector-faq";
|
||||
import { Reveal } from "@/components/connectors-marketing/reveal";
|
||||
import { MarketingSection } from "@/components/marketing/section";
|
||||
import { FAQJsonLd } from "@/components/seo/json-ld";
|
||||
|
||||
/** Answers are 40-60 words, written as quotable definitions for AI Overviews. */
|
||||
export const HOME_FAQ = [
|
||||
{
|
||||
question: "What is open web research?",
|
||||
answer:
|
||||
"Open web research is gathering and analyzing live public information from across the web: search results, community discussions, reviews, videos, and any page. Unlike asking a chatbot that reasons over a stale index, it works from what the web says right now. SurfSense automates it: AI agents collect the live data and turn it into cited briefs and alerts.",
|
||||
},
|
||||
{
|
||||
question: "What is an MCP server?",
|
||||
answer:
|
||||
"An MCP server exposes tools and data to AI agents through the Model Context Protocol, an open standard adopted by Claude, Cursor, and most agent frameworks. Add the SurfSense MCP server and your agents can call every connector, such as reddit.scrape or google_search.scrape, as native tools.",
|
||||
},
|
||||
{
|
||||
question: "How is SurfSense different from a web scraping API?",
|
||||
answer:
|
||||
"A web scraping API returns raw data and leaves the intelligence to you. SurfSense pairs platform-native connectors with an agent harness: retries, structured output, credit metering, and an MCP server, so your agents go from a question to a brief without you building the plumbing in between.",
|
||||
},
|
||||
{
|
||||
question: "Can I use the connector APIs directly in my own app?",
|
||||
answer:
|
||||
"Yes. Every platform connector is a typed REST endpoint you can call from any language with your SurfSense API key, no agent required. Send a POST request with your query and get structured JSON back. Each connector page has copy-paste examples in cURL, Python, JavaScript, Go, and more.",
|
||||
},
|
||||
{
|
||||
question: "Can I self-host SurfSense?",
|
||||
answer:
|
||||
"Yes. SurfSense is open source and self-hostable, so you can run the entire platform on your own infrastructure and keep sensitive research in-house. Use the cloud version to start in minutes, or deploy from the GitHub repository when you need full control.",
|
||||
},
|
||||
];
|
||||
|
||||
export function HomeFaq() {
|
||||
return (
|
||||
<MarketingSection>
|
||||
<FAQJsonLd questions={HOME_FAQ} />
|
||||
<Reveal>
|
||||
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
Frequently asked questions
|
||||
</h2>
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
{/* Accordion capped at a readable measure; left edge stays on the page grid. */}
|
||||
<div className="mt-6 max-w-3xl">
|
||||
<ConnectorFaq items={HOME_FAQ} />
|
||||
</div>
|
||||
</Reveal>
|
||||
</MarketingSection>
|
||||
);
|
||||
}
|
||||
49
surfsense_web/components/homepage/how-it-works.tsx
Normal file
49
surfsense_web/components/homepage/how-it-works.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { Reveal } from "@/components/connectors-marketing/reveal";
|
||||
import { FlowLine } from "@/components/homepage/flow-line";
|
||||
import { MarketingSection } from "@/components/marketing/section";
|
||||
|
||||
/** Numbered because the content is genuinely sequential: connect, gather, act. */
|
||||
const STEPS = [
|
||||
{
|
||||
number: "01",
|
||||
title: "Connect",
|
||||
description:
|
||||
"Grab one API key and call any connector straight from your own code, or add the SurfSense MCP server to Claude, Cursor, or your own agents. Every connector is a REST endpoint and a native agent tool.",
|
||||
},
|
||||
{
|
||||
number: "02",
|
||||
title: "Agents gather",
|
||||
description:
|
||||
"Your agents pull live data through the agent harness: platform connectors, retries, structured output, and credit metering handled for you.",
|
||||
},
|
||||
{
|
||||
number: "03",
|
||||
title: "You act",
|
||||
description:
|
||||
"Get briefs and alerts instead of raw exports. A rank moves, a price changes, a thread turns on you, and you hear about it first.",
|
||||
},
|
||||
];
|
||||
|
||||
export function HowItWorks() {
|
||||
return (
|
||||
<MarketingSection>
|
||||
<Reveal>
|
||||
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">How SurfSense works</h2>
|
||||
</Reveal>
|
||||
<FlowLine />
|
||||
<div className="grid gap-6 md:mt-0 mt-8 md:grid-cols-3">
|
||||
{STEPS.map((step, i) => (
|
||||
<Reveal key={step.number} delay={i * 0.06}>
|
||||
<div className="h-full rounded-xl border bg-card p-6">
|
||||
<span className="font-mono text-sm font-medium text-brand">{step.number}</span>
|
||||
<h3 className="mt-2 text-lg font-semibold">{step.title}</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
|
||||
{step.description}
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</MarketingSection>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,223 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import type React from "react";
|
||||
|
||||
interface Integration {
|
||||
name: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
const INTEGRATIONS: Integration[] = [
|
||||
// Search
|
||||
{ name: "Tavily", icon: "/connectors/tavily.svg" },
|
||||
{ name: "Elasticsearch", icon: "/connectors/elasticsearch.svg" },
|
||||
{ name: "Baidu Search", icon: "/connectors/baidu-search.svg" },
|
||||
{ name: "SearXNG", icon: "/connectors/searxng.svg" },
|
||||
|
||||
// Communication
|
||||
{ name: "Slack", icon: "/connectors/slack.svg" },
|
||||
{ name: "Discord", icon: "/connectors/discord.svg" },
|
||||
{ name: "Gmail", icon: "/connectors/google-gmail.svg" },
|
||||
{ name: "Microsoft Teams", icon: "/connectors/microsoft-teams.svg" },
|
||||
|
||||
// Project Management
|
||||
{ name: "Linear", icon: "/connectors/linear.svg" },
|
||||
{ name: "Jira", icon: "/connectors/jira.svg" },
|
||||
{ name: "ClickUp", icon: "/connectors/clickup.svg" },
|
||||
{ name: "Airtable", icon: "/connectors/airtable.svg" },
|
||||
|
||||
// Documentation & Knowledge
|
||||
{ name: "Confluence", icon: "/connectors/confluence.svg" },
|
||||
{ name: "Notion", icon: "/connectors/notion.svg" },
|
||||
{ name: "BookStack", icon: "/connectors/bookstack.svg" },
|
||||
{ name: "Obsidian", icon: "/connectors/obsidian.svg" },
|
||||
|
||||
// Cloud Storage
|
||||
{ name: "Google Drive", icon: "/connectors/google-drive.svg" },
|
||||
{ name: "OneDrive", icon: "/connectors/onedrive.svg" },
|
||||
{ name: "Dropbox", icon: "/connectors/dropbox.svg" },
|
||||
|
||||
// Development
|
||||
{ name: "GitHub", icon: "/connectors/github.svg" },
|
||||
|
||||
// Productivity
|
||||
{ name: "Google Calendar", icon: "/connectors/google-calendar.svg" },
|
||||
{ name: "Luma", icon: "/connectors/luma.svg" },
|
||||
|
||||
// Media
|
||||
{ name: "YouTube", icon: "/connectors/youtube.svg" },
|
||||
|
||||
// Search
|
||||
{ name: "Linkup", icon: "/connectors/linkup.svg" },
|
||||
|
||||
// Meetings
|
||||
{ name: "Circleback", icon: "/connectors/circleback.svg" },
|
||||
|
||||
// AI
|
||||
{ name: "MCP", icon: "/connectors/modelcontextprotocol.svg" },
|
||||
];
|
||||
|
||||
// 5 vertical columns — 26 icons spread across categories
|
||||
const COLUMNS: number[][] = [
|
||||
[2, 5, 10, 0, 21, 11],
|
||||
[1, 7, 20, 17, 24],
|
||||
[13, 6, 23, 4, 16, 25],
|
||||
[12, 8, 15, 18],
|
||||
[3, 9, 14, 22, 19],
|
||||
];
|
||||
|
||||
// Different scroll speeds per column for organic feel (seconds)
|
||||
const SCROLL_DURATIONS = [26, 32, 22, 30, 28];
|
||||
|
||||
function IntegrationCard({ integration }: { integration: Integration }) {
|
||||
return (
|
||||
<div
|
||||
className="w-[60px] h-[60px] sm:w-[80px] sm:h-[80px] md:w-[120px] md:h-[120px] lg:w-[140px] lg:h-[140px] rounded-[16px] sm:rounded-[20px] md:rounded-[24px] flex items-center justify-center shrink-0 select-none"
|
||||
style={{
|
||||
background: "linear-gradient(145deg, var(--card-from), var(--card-to))",
|
||||
boxShadow: "inset 0 1px 0 0 var(--card-highlight), 0 4px 24px var(--card-shadow)",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={integration.icon}
|
||||
alt={integration.name}
|
||||
className="w-6 h-6 sm:w-7 sm:h-7 md:w-10 md:h-10 lg:w-12 lg:h-12 object-contain select-none pointer-events-none"
|
||||
loading="lazy"
|
||||
draggable={false}
|
||||
width={48}
|
||||
height={48}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScrollingColumn({
|
||||
cards,
|
||||
scrollUp,
|
||||
duration,
|
||||
colIndex,
|
||||
isEdge,
|
||||
isEdgeAdjacent,
|
||||
}: {
|
||||
cards: number[];
|
||||
scrollUp: boolean;
|
||||
duration: number;
|
||||
colIndex: number;
|
||||
isEdge: boolean;
|
||||
isEdgeAdjacent: boolean;
|
||||
}) {
|
||||
// Edge columns get a heavy vertical mask; edge-adjacent columns get a lighter one to smooth the transition
|
||||
const columnMask = isEdge
|
||||
? {
|
||||
maskImage:
|
||||
"linear-gradient(to bottom, transparent 0%, transparent 20%, black 40%, black 60%, transparent 80%, transparent 100%)",
|
||||
WebkitMaskImage:
|
||||
"linear-gradient(to bottom, transparent 0%, transparent 20%, black 40%, black 60%, transparent 80%, transparent 100%)",
|
||||
}
|
||||
: isEdgeAdjacent
|
||||
? {
|
||||
maskImage:
|
||||
"linear-gradient(to bottom, transparent 0%, transparent 10%, black 30%, black 70%, transparent 90%, transparent 100%)",
|
||||
WebkitMaskImage:
|
||||
"linear-gradient(to bottom, transparent 0%, transparent 10%, black 30%, black 70%, transparent 90%, transparent 100%)",
|
||||
}
|
||||
: {};
|
||||
|
||||
const cardSet = cards.map((integrationIndex, i) => (
|
||||
<IntegrationCard
|
||||
key={`${INTEGRATIONS[integrationIndex].name}-c${colIndex}-${i}`}
|
||||
integration={INTEGRATIONS[integrationIndex]}
|
||||
/>
|
||||
));
|
||||
|
||||
return (
|
||||
<div
|
||||
className="shrink-0 overflow-hidden"
|
||||
style={{ ...columnMask, contain: "layout style paint" }}
|
||||
>
|
||||
{/* Outer div has NO gap — each inner copy uses pb matching the gap so both halves are identical in height → seamless -50% loop */}
|
||||
<div
|
||||
className="flex flex-col"
|
||||
style={{
|
||||
animation: `${scrollUp ? "integrations-scroll-up" : "integrations-scroll-down"} ${duration}s linear infinite`,
|
||||
willChange: "transform",
|
||||
transform: "translateZ(0)",
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-2 sm:gap-3 md:gap-5 lg:gap-6 pb-2 sm:pb-3 md:pb-5 lg:pb-6">
|
||||
{cardSet}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:gap-3 md:gap-5 lg:gap-6 pb-2 sm:pb-3 md:pb-5 lg:pb-6">
|
||||
{cardSet}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ExternalIntegrations() {
|
||||
return (
|
||||
<section
|
||||
className={[
|
||||
"relative py-20 md:py-28 overflow-hidden",
|
||||
// No explicit background — inherits the page gradient for seamless blending
|
||||
// CSS custom properties — light mode (card styling)
|
||||
"[--card-from:rgba(255,255,255,0.9)]",
|
||||
"[--card-to:rgba(245,245,248,0.92)]",
|
||||
"[--card-highlight:rgba(255,255,255,0.5)]",
|
||||
"[--card-lowlight:transparent]",
|
||||
"[--card-shadow:transparent]",
|
||||
"[--card-border:transparent]",
|
||||
// CSS custom properties — dark mode (card styling)
|
||||
"dark:[--card-from:rgb(28,28,32)]",
|
||||
"dark:[--card-to:rgb(28,28,32)]",
|
||||
"dark:[--card-highlight:rgba(255,255,255,0.03)]",
|
||||
"dark:[--card-lowlight:rgba(0,0,0,0.1)]",
|
||||
"dark:[--card-shadow:rgba(0,0,0,0.15)]",
|
||||
"dark:[--card-border:rgba(255,255,255,0.03)]",
|
||||
].join(" ")}
|
||||
>
|
||||
{/* Heading */}
|
||||
<div className="text-center mb-12 md:mb-16 relative z-20 px-4">
|
||||
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold text-gray-900 dark:text-white leading-[1.1] tracking-tight">
|
||||
Integrate with your
|
||||
<br />
|
||||
team's most important tools
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Scrolling columns container — masked at edges so the page background shows through seamlessly */}
|
||||
<div
|
||||
className="relative"
|
||||
style={
|
||||
{
|
||||
maskImage:
|
||||
"linear-gradient(to bottom, transparent 0%, black 25%, black 70%, transparent 100%), " +
|
||||
"linear-gradient(to right, transparent 0%, black 12%, black 88%, transparent 100%)",
|
||||
WebkitMaskImage:
|
||||
"linear-gradient(to bottom, transparent 0%, black 25%, black 75%, transparent 100%), " +
|
||||
"linear-gradient(to right, transparent 0%, black 12%, black 88%, transparent 100%)",
|
||||
maskComposite: "intersect",
|
||||
WebkitMaskComposite: "source-in",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{/* 5 scrolling columns */}
|
||||
<div className="flex justify-center gap-2 sm:gap-3 md:gap-5 lg:gap-6 h-[340px] sm:h-[420px] md:h-[560px] lg:h-[640px] overflow-hidden">
|
||||
{COLUMNS.map((column, colIndex) => (
|
||||
<ScrollingColumn
|
||||
key={`col-${SCROLL_DURATIONS[colIndex]}-${colIndex}`}
|
||||
cards={column}
|
||||
scrollUp={colIndex % 2 === 0}
|
||||
duration={SCROLL_DURATIONS[colIndex]}
|
||||
colIndex={colIndex}
|
||||
isEdge={colIndex === 0 || colIndex === COLUMNS.length - 1}
|
||||
isEdgeAdjacent={colIndex === 1 || colIndex === COLUMNS.length - 2}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
111
surfsense_web/components/homepage/logo-cloud.tsx
Normal file
111
surfsense_web/components/homepage/logo-cloud.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Reveal } from "@/components/connectors-marketing/reveal";
|
||||
import { MarketingSection } from "@/components/marketing/section";
|
||||
|
||||
/**
|
||||
* Real signups pulled from prod (Google-auth), curated to the most recognizable
|
||||
* names. These are self-serve users, not signed enterprise accounts, so the
|
||||
* heading stays honest ("Used by people at") rather than implying contracts.
|
||||
*
|
||||
* Logos live in `public/logos/` — official marks from Wikimedia Commons /
|
||||
* Wikipedia (universities use their seals). Bosta, Devoteam, and Leverage Edu
|
||||
* have no clean Wikimedia logo, so they fall back to a brand favicon. Each item
|
||||
* also falls back to a text wordmark on image error.
|
||||
*/
|
||||
const COMPANIES: { title: string; file: string }[] = [
|
||||
{ title: "UC Berkeley", file: "berkeley.svg" },
|
||||
{ title: "USC", file: "usc.svg" },
|
||||
{ title: "Texas A&M", file: "tamu.svg" },
|
||||
{ title: "UW–Madison", file: "wisc.svg" },
|
||||
{ title: "Pitt", file: "pitt.svg" },
|
||||
{ title: "Korean Air", file: "koreanair.svg" },
|
||||
{ title: "Iron Mountain", file: "ironmountain.svg" },
|
||||
{ title: "Globant", file: "globant.svg" },
|
||||
{ title: "Devoteam", file: "devoteam.png" },
|
||||
{ title: "VNG", file: "vng.svg" },
|
||||
{ title: "TPBank", file: "tpbank.svg" },
|
||||
{ title: "OpenGov", file: "opengov.png" },
|
||||
{ title: "WeLab", file: "welab.png" },
|
||||
{ title: "Leverage Edu", file: "leverageedu.png" },
|
||||
{ title: "Zopper", file: "zopper.png" },
|
||||
{ title: "Tec de Monterrey", file: "tec.svg" },
|
||||
{ title: "Chulalongkorn", file: "chula.svg" },
|
||||
{ title: "Univ. of Bristol", file: "bristol.svg" },
|
||||
{ title: "Nutresa", file: "nutresa.svg" },
|
||||
{ title: "Bosta", file: "bosta.png" },
|
||||
];
|
||||
|
||||
const LOGOS_PER_SET = 10;
|
||||
const TOTAL_SETS = Math.ceil(COMPANIES.length / LOGOS_PER_SET);
|
||||
|
||||
function LogoItem({ title, file }: { title: string; file: string }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<span className="text-sm font-semibold text-neutral-500 dark:text-neutral-400">{title}</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
// biome-ignore lint/performance/noImgElement: swapped in/out by the cycling animation, next/image adds no value here
|
||||
<img
|
||||
src={`/logos/${file}`}
|
||||
alt={title}
|
||||
title={title}
|
||||
width={130}
|
||||
height={40}
|
||||
loading="lazy"
|
||||
onError={() => setFailed(true)}
|
||||
// dark mode: dark-on-transparent marks would vanish, so render every logo as a
|
||||
// uniform light silhouette (brightness-0 + invert) instead of relying on its own color
|
||||
className="h-10 w-auto max-w-[130px] object-contain opacity-60 grayscale transition duration-300 hover:opacity-100 hover:grayscale-0 dark:opacity-70 dark:brightness-0 dark:invert dark:hover:opacity-100"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function LogoCloud() {
|
||||
const [currentSet, setCurrentSet] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setCurrentSet((prev) => (prev + 1) % TOTAL_SETS);
|
||||
}, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const startIndex = currentSet * LOGOS_PER_SET;
|
||||
const currentLogos = Array.from(
|
||||
{ length: LOGOS_PER_SET },
|
||||
(_, i) => COMPANIES[(startIndex + i) % COMPANIES.length]
|
||||
);
|
||||
|
||||
return (
|
||||
<MarketingSection>
|
||||
<Reveal>
|
||||
<h2 className="mx-auto max-w-xl text-center text-lg font-medium text-neutral-600 dark:text-neutral-400">
|
||||
Used by people at
|
||||
</h2>
|
||||
</Reveal>
|
||||
<div className="mx-auto mt-10 grid max-w-4xl grid-cols-3 gap-8 sm:grid-cols-5">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{currentLogos.map((logo, index) => (
|
||||
<motion.div
|
||||
key={`${logo.title}-${currentSet}-${index}`}
|
||||
initial={{ x: 40, opacity: 0, filter: "blur(8px)" }}
|
||||
animate={{ x: 0, opacity: 1, filter: "blur(0px)" }}
|
||||
exit={{ x: -40, opacity: 0, filter: "blur(8px)" }}
|
||||
transition={{ duration: 0.4, ease: "easeOut", delay: index * 0.05 }}
|
||||
className="flex items-center justify-center"
|
||||
>
|
||||
<LogoItem title={logo.title} file={logo.file} />
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</MarketingSection>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,8 +1,18 @@
|
|||
"use client";
|
||||
import { IconBrandDiscord, IconBrandReddit, IconMenu2, IconX } from "@tabler/icons-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import {
|
||||
IconBook,
|
||||
IconBrandDiscord,
|
||||
IconBrandReddit,
|
||||
IconChevronDown,
|
||||
IconMenu2,
|
||||
IconNews,
|
||||
IconSparkles,
|
||||
IconSpeakerphone,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Fragment, useEffect, useRef, useState } from "react";
|
||||
import { SignInButton } from "@/components/auth/sign-in-button";
|
||||
import { NavbarGitHubStars } from "@/components/homepage/github-stars-badge";
|
||||
import { Logo } from "@/components/Logo";
|
||||
|
|
@ -15,6 +25,38 @@ interface NavItem {
|
|||
link: string;
|
||||
}
|
||||
|
||||
interface ResourceItem extends NavItem {
|
||||
description: string;
|
||||
icon: typeof IconNews;
|
||||
}
|
||||
|
||||
const resourceItems: ResourceItem[] = [
|
||||
{
|
||||
name: "Blog",
|
||||
link: "/blog",
|
||||
description: "Guides, comparisons, and deep dives",
|
||||
icon: IconNews,
|
||||
},
|
||||
{
|
||||
name: "Announcements",
|
||||
link: "/announcements",
|
||||
description: "Product news and updates",
|
||||
icon: IconSpeakerphone,
|
||||
},
|
||||
{
|
||||
name: "Changelog",
|
||||
link: "/changelog",
|
||||
description: "What's new in SurfSense",
|
||||
icon: IconSparkles,
|
||||
},
|
||||
{
|
||||
name: "Docs",
|
||||
link: "/docs",
|
||||
description: "Setup, connectors, and API reference",
|
||||
icon: IconBook,
|
||||
},
|
||||
];
|
||||
|
||||
interface NavbarProps {
|
||||
/** Override the scrolled-state background classes (desktop & mobile). */
|
||||
scrolledBgClassName?: string;
|
||||
|
|
@ -35,13 +77,11 @@ interface MobileNavProps {
|
|||
export const Navbar = ({ scrolledBgClassName }: NavbarProps = {}) => {
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
|
||||
const navItems = [
|
||||
{ name: "Free\u00A0AI", link: "/free" },
|
||||
const navItems: NavItem[] = [
|
||||
{ name: "Connectors", link: "/connectors" },
|
||||
{ name: "Pricing", link: "/pricing" },
|
||||
{ name: "Blog", link: "/blog" },
|
||||
{ name: "Changelog", link: "/changelog" },
|
||||
{ name: "Docs", link: "/docs" },
|
||||
{ name: "Contact\u00A0Us", link: "/contact" },
|
||||
{ name: "Free\u00A0AI", link: "/free" },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -72,6 +112,96 @@ export const Navbar = ({ scrolledBgClassName }: NavbarProps = {}) => {
|
|||
);
|
||||
};
|
||||
|
||||
const ResourcesDropdown = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const closeTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const openMenu = () => {
|
||||
if (closeTimeout.current) clearTimeout(closeTimeout.current);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
// ponytail: small close delay bridges the pointer gap between trigger and panel
|
||||
const closeMenu = () => {
|
||||
closeTimeout.current = setTimeout(() => setOpen(false), 100);
|
||||
};
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: hover intent only; keyboard access lives on the button
|
||||
<div
|
||||
className="relative"
|
||||
onMouseEnter={openMenu}
|
||||
onMouseLeave={closeMenu}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) setOpen(false);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-1 rounded-full px-4 py-2 text-neutral-600 outline-none transition-colors dark:text-neutral-300",
|
||||
open && "bg-gray-100 dark:bg-neutral-800"
|
||||
)}
|
||||
>
|
||||
Resources
|
||||
<IconChevronDown
|
||||
className={cn("h-3.5 w-3.5 transition-transform duration-200", open && "rotate-180")}
|
||||
/>
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<div className="absolute left-1/2 top-full -translate-x-1/2 pt-2">
|
||||
<motion.div
|
||||
initial={{
|
||||
opacity: 0,
|
||||
scale: shouldReduceMotion ? 1 : 0.95,
|
||||
y: shouldReduceMotion ? 0 : 6,
|
||||
}}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
scale: shouldReduceMotion ? 1 : 0.97,
|
||||
y: shouldReduceMotion ? 0 : 4,
|
||||
transition: { duration: 0.12, ease: "easeIn" },
|
||||
}}
|
||||
transition={{ type: "spring", duration: 0.3, bounce: 0.15 }}
|
||||
className="w-72 origin-top overflow-hidden rounded-2xl border border-white/20 bg-white/90 p-2 shadow-2xl backdrop-blur-xl dark:border-neutral-800/50 dark:bg-neutral-950/90"
|
||||
>
|
||||
{resourceItems.map((item) => (
|
||||
<Link
|
||||
key={item.link}
|
||||
href={item.link}
|
||||
onClick={() => setOpen(false)}
|
||||
className="group flex items-start gap-3 rounded-xl px-3 py-2.5 transition-colors hover:bg-gray-100 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<span className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-white text-neutral-500 transition-colors group-hover:text-neutral-900 dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-400 dark:group-hover:text-white">
|
||||
<item.icon className="h-4 w-4" aria-hidden />
|
||||
</span>
|
||||
<span className="flex flex-col">
|
||||
<span className="text-sm font-medium text-neutral-800 dark:text-neutral-100">
|
||||
{item.name}
|
||||
</span>
|
||||
<span className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{item.description}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DesktopNav = ({ navItems, isScrolled, scrolledBgClassName }: DesktopNavProps) => {
|
||||
const [hovered, setHovered] = useState<number | null>(null);
|
||||
return (
|
||||
|
|
@ -96,21 +226,23 @@ const DesktopNav = ({ navItems, isScrolled, scrolledBgClassName }: DesktopNavPro
|
|||
</Link>
|
||||
<div className="hidden flex-1 flex-row items-center justify-center space-x-2 text-sm font-medium text-zinc-600 transition duration-200 hover:text-zinc-800 lg:flex lg:space-x-2">
|
||||
{navItems.map((navItem: NavItem, idx: number) => (
|
||||
<Link
|
||||
onMouseEnter={() => setHovered(idx)}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
className="relative px-4 py-2 text-neutral-600 dark:text-neutral-300"
|
||||
key={navItem.link}
|
||||
href={navItem.link}
|
||||
>
|
||||
{hovered === idx && (
|
||||
<motion.div
|
||||
layoutId="hovered"
|
||||
className="absolute inset-0 h-full w-full rounded-full bg-gray-100 dark:bg-neutral-800"
|
||||
/>
|
||||
)}
|
||||
<span className="relative z-20">{navItem.name}</span>
|
||||
</Link>
|
||||
<Fragment key={navItem.link}>
|
||||
<Link
|
||||
onMouseEnter={() => setHovered(idx)}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
className="relative px-4 py-2 text-neutral-600 dark:text-neutral-300"
|
||||
href={navItem.link}
|
||||
>
|
||||
{hovered === idx && (
|
||||
<motion.div
|
||||
layoutId="hovered"
|
||||
className="absolute inset-0 h-full w-full rounded-full bg-gray-100 dark:bg-neutral-800"
|
||||
/>
|
||||
)}
|
||||
<span className="relative z-20">{navItem.name}</span>
|
||||
</Link>
|
||||
{navItem.link === "/pricing" && <ResourcesDropdown />}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-end gap-2">
|
||||
|
|
@ -118,17 +250,22 @@ const DesktopNav = ({ navItems, isScrolled, scrolledBgClassName }: DesktopNavPro
|
|||
href="https://discord.gg/ejRNvftDp9"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="SurfSense on Discord"
|
||||
className="hidden rounded-full p-2 hover:bg-gray-100 dark:hover:bg-neutral-800 transition-colors md:flex items-center justify-center"
|
||||
>
|
||||
<IconBrandDiscord className="h-5 w-5 text-neutral-600 dark:text-neutral-300" />
|
||||
<IconBrandDiscord
|
||||
className="h-5 w-5 text-neutral-600 dark:text-neutral-300"
|
||||
aria-hidden
|
||||
/>
|
||||
</Link>
|
||||
<Link
|
||||
href="https://www.reddit.com/r/SurfSense/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="SurfSense on Reddit"
|
||||
className="hidden rounded-full p-2 hover:bg-gray-100 dark:hover:bg-neutral-800 transition-colors md:flex items-center justify-center"
|
||||
>
|
||||
<IconBrandReddit className="h-5 w-5 text-neutral-600 dark:text-neutral-300" />
|
||||
<IconBrandReddit className="h-5 w-5 text-neutral-600 dark:text-neutral-300" aria-hidden />
|
||||
</Link>
|
||||
<NavbarGitHubStars className="hidden md:flex" />
|
||||
<ThemeTogglerComponent />
|
||||
|
|
@ -206,30 +343,49 @@ const MobileNav = ({ navItems, isScrolled, scrolledBgClassName }: MobileNavProps
|
|||
className="absolute inset-x-0 top-full mt-1 z-20 flex w-full flex-col items-start justify-start gap-4 rounded-xl bg-white/90 backdrop-blur-xl border border-white/20 shadow-2xl px-4 py-6 dark:bg-neutral-950/90 dark:border-neutral-800/50"
|
||||
>
|
||||
{navItems.map((navItem: NavItem) => (
|
||||
<Link
|
||||
key={navItem.link}
|
||||
href={navItem.link}
|
||||
className="relative text-neutral-600 dark:text-neutral-300"
|
||||
>
|
||||
<motion.span className="block">{navItem.name} </motion.span>
|
||||
</Link>
|
||||
<Fragment key={navItem.link}>
|
||||
<Link
|
||||
href={navItem.link}
|
||||
className="relative text-neutral-600 dark:text-neutral-300"
|
||||
>
|
||||
<motion.span className="block">{navItem.name} </motion.span>
|
||||
</Link>
|
||||
{navItem.link === "/pricing" &&
|
||||
resourceItems.map((item) => (
|
||||
<Link
|
||||
key={item.link}
|
||||
href={item.link}
|
||||
className="relative text-neutral-600 dark:text-neutral-300"
|
||||
>
|
||||
<motion.span className="block">{item.name} </motion.span>
|
||||
</Link>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
<div className="flex w-full items-center gap-2 pt-2">
|
||||
<Link
|
||||
href="https://discord.gg/ejRNvftDp9"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="SurfSense on Discord"
|
||||
className="flex items-center justify-center rounded-lg p-2 hover:bg-gray-100 dark:hover:bg-neutral-800 transition-colors touch-manipulation"
|
||||
>
|
||||
<IconBrandDiscord className="h-5 w-5 text-neutral-600 dark:text-neutral-300" />
|
||||
<IconBrandDiscord
|
||||
className="h-5 w-5 text-neutral-600 dark:text-neutral-300"
|
||||
aria-hidden
|
||||
/>
|
||||
</Link>
|
||||
<Link
|
||||
href="https://www.reddit.com/r/SurfSense/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="SurfSense on Reddit"
|
||||
className="flex items-center justify-center rounded-lg p-2 hover:bg-gray-100 dark:hover:bg-neutral-800 transition-colors touch-manipulation"
|
||||
>
|
||||
<IconBrandReddit className="h-5 w-5 text-neutral-600 dark:text-neutral-300" />
|
||||
<IconBrandReddit
|
||||
className="h-5 w-5 text-neutral-600 dark:text-neutral-300"
|
||||
aria-hidden
|
||||
/>
|
||||
</Link>
|
||||
<NavbarGitHubStars className="rounded-lg" />
|
||||
<ThemeTogglerComponent />
|
||||
|
|
|
|||
86
surfsense_web/components/homepage/persona-paths.tsx
Normal file
86
surfsense_web/components/homepage/persona-paths.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { ArrowRight, Code2, Megaphone } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Reveal } from "@/components/connectors-marketing/reveal";
|
||||
import { UseCaseArt, type UseCaseArtVariant } from "@/components/homepage/use-case-art";
|
||||
import { MarketingSection } from "@/components/marketing/section";
|
||||
|
||||
/**
|
||||
* Answers "is this for me?" right below the hero: one card per audience.
|
||||
* Revenue persona (founders / marketing teams) first, growth persona
|
||||
* (developers / agent builders) second.
|
||||
*/
|
||||
const PATHS: {
|
||||
icon: typeof Megaphone;
|
||||
art: UseCaseArtVariant;
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
links: { label: string; href: string }[];
|
||||
}[] = [
|
||||
{
|
||||
icon: Megaphone,
|
||||
art: "chat",
|
||||
eyebrow: "For founders & marketing teams",
|
||||
title: "Live web research without the enterprise price tag",
|
||||
description:
|
||||
"Ask for a research brief, a lead list, or a competitor teardown in plain English. The agent gathers live data, cites its sources, and automations keep watch so you hear about changes first. Start free, pay only for what you use.",
|
||||
links: [
|
||||
{ label: "See what teams build", href: "/connectors" },
|
||||
{ label: "Pricing", href: "/pricing" },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: Code2,
|
||||
art: "api",
|
||||
eyebrow: "For developers & agents",
|
||||
title: "The whole platform is programmable",
|
||||
description:
|
||||
"Everything SurfSense agents can do is a typed REST API: scrape Reddit, YouTube, TikTok, Amazon, Walmart, Google Maps, Google Search, and the open web, search the knowledge base, run automations. One key, JSON in and out, $5 free credit, pay as you go. Already running agents in Claude, Cursor, or your own harness? The SurfSense MCP server hands them the same tools natively.",
|
||||
links: [
|
||||
{ label: "Read the docs", href: "/docs" },
|
||||
{ label: "SurfSense MCP server", href: "/mcp-server" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function PersonaPaths() {
|
||||
return (
|
||||
<MarketingSection>
|
||||
<Reveal>
|
||||
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">Who SurfSense is for</h2>
|
||||
</Reveal>
|
||||
<div className="mt-8 grid gap-6 md:grid-cols-2">
|
||||
{PATHS.map((path) => {
|
||||
const Icon = path.icon;
|
||||
return (
|
||||
<Reveal key={path.eyebrow}>
|
||||
<div className="flex h-full flex-col rounded-xl border bg-card p-6">
|
||||
<UseCaseArt variant={path.art} />
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-brand">
|
||||
<Icon className="size-4" aria-hidden />
|
||||
{path.eyebrow}
|
||||
</div>
|
||||
<h3 className="mt-3 text-lg font-semibold">{path.title}</h3>
|
||||
<p className="mt-2 flex-1 text-sm leading-relaxed text-muted-foreground">
|
||||
{path.description}
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap gap-4">
|
||||
{path.links.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="group inline-flex items-center gap-1 text-sm font-medium text-foreground"
|
||||
>
|
||||
{link.label}
|
||||
<ArrowRight className="size-4 transition-transform group-hover:translate-x-0.5" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</MarketingSection>
|
||||
);
|
||||
}
|
||||
298
surfsense_web/components/homepage/social-proof.tsx
Normal file
298
surfsense_web/components/homepage/social-proof.tsx
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
IconBrandLinkedin,
|
||||
IconBrandTiktok,
|
||||
IconBrandX,
|
||||
IconBrandYoutube,
|
||||
} from "@tabler/icons-react";
|
||||
import { Component, type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { LinkedInEmbed, TikTokEmbed, XEmbed, YouTubeEmbed } from "react-social-media-embed";
|
||||
import { Reveal } from "@/components/connectors-marketing/reveal";
|
||||
|
||||
type Post =
|
||||
| { kind: "youtube"; url: string; title: string; channel: string }
|
||||
| { kind: "x"; url: string }
|
||||
| { kind: "linkedin"; url: string; postUrl: string }
|
||||
| { kind: "tiktok"; url: string };
|
||||
|
||||
/**
|
||||
* Organic SurfSense coverage — real posts embedded live with react-social-media-embed,
|
||||
* grouped into three platform-uniform marquee rows (heights match within a row).
|
||||
*
|
||||
* Note: LinkedIn only renders when the author enabled embedding on the post; if
|
||||
* disabled, the EmbedBoundary swaps in a link card to the original.
|
||||
*
|
||||
* ponytail: three rows, each list duplicated once for a seamless CSS loop. Ceiling:
|
||||
* heavy third-party embeds. Mitigated by lazy-mounting the whole section on scroll
|
||||
* (IntersectionObserver). Upgrade path: per-card virtualization, or swap YouTube
|
||||
* players for thumbnail links.
|
||||
*/
|
||||
const YOUTUBE: Post[] = [
|
||||
{
|
||||
kind: "youtube",
|
||||
url: "https://www.youtube.com/watch?v=i9AJ7PHGSGg",
|
||||
title: "SurfSense vs NotebookLM",
|
||||
channel: "rezasaad plus",
|
||||
},
|
||||
{
|
||||
kind: "youtube",
|
||||
url: "https://www.youtube.com/watch?v=VBOwuD6xVK0",
|
||||
title: "NotebookLM Is Great… Until You See SurfSense",
|
||||
channel: "Thomas AI",
|
||||
},
|
||||
{
|
||||
kind: "youtube",
|
||||
url: "https://www.youtube.com/watch?v=UaekqjhUiJM",
|
||||
title: "NotebookLM vs SurfSense en 6 pruebas reales (sorprendente)",
|
||||
channel: "NextGen IA Hub",
|
||||
},
|
||||
{
|
||||
kind: "youtube",
|
||||
url: "https://www.youtube.com/watch?v=QGjKpZJJ9aw",
|
||||
title: "Gana DINERO configurando “Cerebros de IA” privados con SurfSense",
|
||||
channel: "Creando Con La IA",
|
||||
},
|
||||
{
|
||||
kind: "youtube",
|
||||
url: "https://www.youtube.com/watch?v=cfNAIQtNbKY",
|
||||
title: "¿Adiós NotebookLM? Probé SurfSense y es BRUTAL (IA Gratis)",
|
||||
channel: "NextGen IA Hub",
|
||||
},
|
||||
{
|
||||
kind: "youtube",
|
||||
url: "https://www.youtube.com/watch?v=pIWOKSHhf38",
|
||||
title: "¿Superaron a NotebookLM? SurfSense es Open Source, privada y GRATIS",
|
||||
channel: "academIArtificial",
|
||||
},
|
||||
{
|
||||
kind: "youtube",
|
||||
url: "https://www.youtube.com/watch?v=K5xx-J_mQZ8",
|
||||
title: "¿Mejor que NotebookLM? IA GRATIS con modo local",
|
||||
channel: "Migue Baena IA",
|
||||
},
|
||||
{
|
||||
kind: "youtube",
|
||||
url: "https://www.youtube.com/watch?v=AKxM3RUBFsc",
|
||||
title: "¿Nueva IA GRATIS destroza a NotebookLM de Google? (OPEN SOURCE)",
|
||||
channel: "Inteligencia Artificial Top",
|
||||
},
|
||||
{
|
||||
kind: "youtube",
|
||||
url: "https://www.youtube.com/watch?v=jCAgeaVgPDA",
|
||||
title: "¿Nueva Herramienta IA GRATIS Destroza a NotebookLM? (OPEN SOURCE)",
|
||||
channel: "Joaquín Barberá",
|
||||
},
|
||||
];
|
||||
|
||||
const TWEETS: Post[] = [
|
||||
{ kind: "x", url: "https://x.com/LangChain/status/1853133037019562434" },
|
||||
{ kind: "x", url: "https://x.com/MoureDev/status/1976279289780740448" },
|
||||
{ kind: "x", url: "https://x.com/GithubProjects/status/2004892541590929490" },
|
||||
{ kind: "x", url: "https://x.com/GitHub_Daily/status/1920418408736436438" },
|
||||
{ kind: "x", url: "https://x.com/tom_doerr/status/2066062170173977088" },
|
||||
{ kind: "x", url: "https://x.com/itsharmanjot/status/2066118517905354816" },
|
||||
{ kind: "x", url: "https://x.com/JulianGoldieSEO/status/2011085275133604095" },
|
||||
{ kind: "x", url: "https://x.com/L_go_mrk/status/2066482853232115847" },
|
||||
{ kind: "x", url: "https://x.com/semihdev/status/2006275500952736028" },
|
||||
{ kind: "x", url: "https://x.com/shao__meng/status/1919912860957999494" },
|
||||
{ kind: "x", url: "https://x.com/LangChain/status/1840406184316342561" },
|
||||
];
|
||||
|
||||
const SOCIAL: Post[] = [
|
||||
{
|
||||
kind: "linkedin",
|
||||
url: "https://www.linkedin.com/embed/feed/update/urn:li:ugcPost:7448203908834938881",
|
||||
postUrl:
|
||||
"https://www.linkedin.com/posts/vikas-singh-546643206_most-ai-tools-live-in-your-browser-and-ugcPost-7448203908834938881-gR6y",
|
||||
},
|
||||
{
|
||||
kind: "linkedin",
|
||||
url: "https://www.linkedin.com/embed/feed/update/urn:li:share:7448351685409701889",
|
||||
postUrl:
|
||||
"https://www.linkedin.com/posts/neha-jain-279b80118_ive-been-using-a-lot-of-ai-tools-daily-share-7448351685409701889-JvFP",
|
||||
},
|
||||
{
|
||||
kind: "tiktok",
|
||||
url: "https://www.tiktok.com/@alejavirivera/video/7603064928114625814",
|
||||
},
|
||||
];
|
||||
|
||||
const CARD = "mr-4 shrink-0 overflow-hidden rounded-xl border bg-card";
|
||||
|
||||
const SIZE: Record<Post["kind"], string> = {
|
||||
youtube: "h-[262px] w-[340px]",
|
||||
x: "h-[440px] w-[340px]",
|
||||
linkedin: "h-[540px] w-[340px]",
|
||||
tiktok: "h-[540px] w-[340px]",
|
||||
};
|
||||
|
||||
const META: Record<Post["kind"], { Icon: typeof IconBrandX; label: string }> = {
|
||||
youtube: { Icon: IconBrandYoutube, label: "YouTube" },
|
||||
x: { Icon: IconBrandX, label: "X" },
|
||||
linkedin: { Icon: IconBrandLinkedin, label: "LinkedIn" },
|
||||
tiktok: { Icon: IconBrandTiktok, label: "TikTok" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Some embeds (notably Facebook, and LinkedIn when the author disabled embedding)
|
||||
* throw at render/mount instead of degrading gracefully. This boundary stops one
|
||||
* bad embed from taking down the whole marquee — it swaps in a link card instead.
|
||||
*/
|
||||
class EmbedBoundary extends Component<
|
||||
{ fallback: ReactNode; children: ReactNode },
|
||||
{ failed: boolean }
|
||||
> {
|
||||
state = { failed: false };
|
||||
static getDerivedStateFromError() {
|
||||
return { failed: true };
|
||||
}
|
||||
render() {
|
||||
return this.state.failed ? this.props.fallback : this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
function FallbackCard({ post }: { post: Post }) {
|
||||
const { Icon, label } = META[post.kind];
|
||||
const href = post.kind === "linkedin" ? post.postUrl : post.url;
|
||||
return (
|
||||
<div className={`${CARD} ${SIZE[post.kind]}`}>
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex h-full w-full flex-col items-center justify-center gap-3 p-6 text-center text-sm font-medium text-muted-foreground transition-colors hover:text-brand"
|
||||
>
|
||||
<Icon className="size-8" aria-hidden />
|
||||
<span>View this post on {label}</span>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({ post }: { post: Post }) {
|
||||
switch (post.kind) {
|
||||
case "youtube":
|
||||
return (
|
||||
<div className={`${CARD} ${SIZE.youtube} flex flex-col`}>
|
||||
<YouTubeEmbed url={post.url} width={340} height={191} />
|
||||
<div className="flex flex-1 flex-col gap-1.5 p-3">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
<IconBrandYoutube className="size-4 shrink-0 text-red-500" aria-hidden />
|
||||
<span className="truncate">{post.channel}</span>
|
||||
</div>
|
||||
<a
|
||||
href={post.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="line-clamp-2 text-sm font-medium leading-snug hover:text-brand"
|
||||
>
|
||||
{post.title}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case "x":
|
||||
return (
|
||||
<div className={`${CARD} ${SIZE.x}`}>
|
||||
<XEmbed url={post.url} width={340} />
|
||||
</div>
|
||||
);
|
||||
case "linkedin":
|
||||
return (
|
||||
<div className={`${CARD} ${SIZE.linkedin}`}>
|
||||
<LinkedInEmbed url={post.url} postUrl={post.postUrl} width={340} height={540} />
|
||||
</div>
|
||||
);
|
||||
case "tiktok":
|
||||
return (
|
||||
<div className={`${CARD} ${SIZE.tiktok}`}>
|
||||
<TikTokEmbed url={post.url} width={340} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function Row({
|
||||
posts,
|
||||
animation,
|
||||
duration,
|
||||
}: {
|
||||
posts: Post[];
|
||||
animation: "ss-marquee-l" | "ss-marquee-r";
|
||||
duration: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="group flex overflow-hidden">
|
||||
{/* Track holds the list twice; the -50% shift wraps seamlessly (margins, not gap). */}
|
||||
<div
|
||||
className="flex w-max shrink-0 group-hover:paused motion-reduce:paused"
|
||||
style={{ animation: `${animation} ${duration}s linear infinite` }}
|
||||
>
|
||||
{[...posts, ...posts].map((post, i) => (
|
||||
<EmbedBoundary
|
||||
key={`${post.kind}-${post.url}-${i}`}
|
||||
fallback={<FallbackCard post={post} />}
|
||||
>
|
||||
<Card post={post} />
|
||||
</EmbedBoundary>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SocialProof() {
|
||||
// Third-party embeds are heavy; only mount them once the section scrolls near view.
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting) {
|
||||
setVisible(true);
|
||||
io.disconnect();
|
||||
}
|
||||
},
|
||||
{ rootMargin: "300px" }
|
||||
);
|
||||
io.observe(el);
|
||||
return () => io.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section className="overflow-hidden py-12 sm:py-16">
|
||||
<Reveal>
|
||||
<div className="mx-auto max-w-2xl px-4 text-center">
|
||||
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
Loved across the internet
|
||||
</h2>
|
||||
</div>
|
||||
</Reveal>
|
||||
<div
|
||||
ref={ref}
|
||||
className="mt-10 flex min-h-[1360px] flex-col gap-4"
|
||||
style={{
|
||||
maskImage: "linear-gradient(to right, transparent, black 6%, black 94%, transparent)",
|
||||
WebkitMaskImage:
|
||||
"linear-gradient(to right, transparent, black 6%, black 94%, transparent)",
|
||||
}}
|
||||
>
|
||||
{visible ? (
|
||||
<>
|
||||
<Row posts={YOUTUBE} animation="ss-marquee-l" duration={60} />
|
||||
<Row posts={TWEETS} animation="ss-marquee-r" duration={75} />
|
||||
{/* Only 3 social cards — pre-double so one set spans wide viewports (no loop gap). */}
|
||||
<Row posts={[...SOCIAL, ...SOCIAL]} animation="ss-marquee-l" duration={70} />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes ss-marquee-l { from { transform: translateX(0); } to { transform: translateX(-50%); } }
|
||||
@keyframes ss-marquee-r { from { transform: translateX(-50%); } to { transform: translateX(0); } }
|
||||
`}</style>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
444
surfsense_web/components/homepage/use-case-art.tsx
Normal file
444
surfsense_web/components/homepage/use-case-art.tsx
Normal file
|
|
@ -0,0 +1,444 @@
|
|||
"use client";
|
||||
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
|
||||
const EASE_OUT: [number, number, number, number] = [0.16, 1, 0.3, 1];
|
||||
const VIEWPORT = { once: true, amount: 0.4 } as const;
|
||||
|
||||
export type UseCaseArtVariant = "price" | "brand" | "leads" | "serp" | "chat" | "api";
|
||||
|
||||
/** Soft infinite pulse ring marking the "live" signal in each artifact. */
|
||||
function Pulse({
|
||||
cx,
|
||||
cy,
|
||||
reduce,
|
||||
delay = 0,
|
||||
}: {
|
||||
cx: number;
|
||||
cy: number;
|
||||
reduce: boolean;
|
||||
delay?: number;
|
||||
}) {
|
||||
if (reduce) return null;
|
||||
return (
|
||||
<motion.circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={5}
|
||||
className="fill-brand/40"
|
||||
style={{ transformBox: "fill-box", transformOrigin: "center" }}
|
||||
animate={{ scale: [1, 2.4], opacity: [0.5, 0] }}
|
||||
transition={{ duration: 2, ease: "easeOut", repeat: Infinity, delay }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Competitor price monitoring: a price line steps up; the change point alerts. */
|
||||
function PriceArt({ reduce }: { reduce: boolean }) {
|
||||
return (
|
||||
<motion.svg
|
||||
viewBox="0 0 240 96"
|
||||
className="h-auto w-full"
|
||||
initial={reduce ? undefined : "hidden"}
|
||||
whileInView="visible"
|
||||
viewport={VIEWPORT}
|
||||
>
|
||||
{/* Grid */}
|
||||
{[24, 48, 72].map((y) => (
|
||||
<line
|
||||
key={y}
|
||||
x1="12"
|
||||
y1={y}
|
||||
x2="228"
|
||||
y2={y}
|
||||
className="stroke-muted-foreground/15"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
))}
|
||||
{/* Price line with a step change at x=132 */}
|
||||
<motion.path
|
||||
d="M 12 66 L 56 64 L 96 62 L 132 62 L 132 38 L 176 36 L 228 34"
|
||||
fill="none"
|
||||
className="stroke-brand"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
variants={{ hidden: { pathLength: 0 }, visible: { pathLength: 1 } }}
|
||||
transition={{ duration: 0.9, ease: EASE_OUT }}
|
||||
/>
|
||||
{/* Alert at the step */}
|
||||
<Pulse cx={132} cy={38} reduce={reduce} delay={0.9} />
|
||||
<motion.circle
|
||||
cx="132"
|
||||
cy="38"
|
||||
r="4"
|
||||
className="fill-brand"
|
||||
variants={{ hidden: { opacity: 0, scale: 0 }, visible: { opacity: 1, scale: 1 } }}
|
||||
style={{ transformBox: "fill-box", transformOrigin: "center" }}
|
||||
transition={{ duration: 0.3, ease: EASE_OUT, delay: 0.7 }}
|
||||
/>
|
||||
{/* Price-change tag */}
|
||||
<motion.g
|
||||
variants={{ hidden: { opacity: 0, y: 6 }, visible: { opacity: 1, y: 0 } }}
|
||||
transition={{ duration: 0.3, ease: EASE_OUT, delay: 0.85 }}
|
||||
>
|
||||
<rect x="146" y="46" width="52" height="18" rx="4" className="fill-brand/10" />
|
||||
<text x="172" y="59" textAnchor="middle" className="fill-brand text-[10px] font-medium">
|
||||
+$10/mo
|
||||
</text>
|
||||
</motion.g>
|
||||
</motion.svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Brand monitoring: a listening radar; mentions blip in around the brand. */
|
||||
function BrandArt({ reduce }: { reduce: boolean }) {
|
||||
const blips = [
|
||||
{ cx: 84, cy: 30, delay: 0.5 },
|
||||
{ cx: 168, cy: 38, delay: 0.7 },
|
||||
{ cx: 100, cy: 70, delay: 0.9 },
|
||||
];
|
||||
return (
|
||||
<motion.svg
|
||||
viewBox="0 0 240 96"
|
||||
className="h-auto w-full"
|
||||
initial={reduce ? undefined : "hidden"}
|
||||
whileInView="visible"
|
||||
viewport={VIEWPORT}
|
||||
>
|
||||
{/* Radar rings */}
|
||||
{[16, 30, 44].map((r, i) => (
|
||||
<motion.circle
|
||||
key={r}
|
||||
cx="120"
|
||||
cy="48"
|
||||
r={r}
|
||||
fill="none"
|
||||
className="stroke-muted-foreground/20"
|
||||
strokeWidth="1"
|
||||
variants={{ hidden: { opacity: 0, scale: 0.6 }, visible: { opacity: 1, scale: 1 } }}
|
||||
style={{ transformBox: "fill-box", transformOrigin: "center" }}
|
||||
transition={{ duration: 0.45, ease: EASE_OUT, delay: i * 0.12 }}
|
||||
/>
|
||||
))}
|
||||
{/* Brand at the center */}
|
||||
<motion.circle
|
||||
cx="120"
|
||||
cy="48"
|
||||
r="4"
|
||||
className="fill-brand"
|
||||
variants={{ hidden: { opacity: 0, scale: 0 }, visible: { opacity: 1, scale: 1 } }}
|
||||
style={{ transformBox: "fill-box", transformOrigin: "center" }}
|
||||
transition={{ duration: 0.3, ease: EASE_OUT, delay: 0.35 }}
|
||||
/>
|
||||
<Pulse cx={120} cy={48} reduce={reduce} delay={0.6} />
|
||||
{/* Mention blips */}
|
||||
{blips.map((blip) => (
|
||||
<motion.circle
|
||||
key={`${blip.cx}-${blip.cy}`}
|
||||
cx={blip.cx}
|
||||
cy={blip.cy}
|
||||
r="3"
|
||||
className="fill-brand/70"
|
||||
variants={{ hidden: { opacity: 0, scale: 0 }, visible: { opacity: 1, scale: 1 } }}
|
||||
style={{ transformBox: "fill-box", transformOrigin: "center" }}
|
||||
transition={{ duration: 0.3, ease: EASE_OUT, delay: blip.delay }}
|
||||
/>
|
||||
))}
|
||||
</motion.svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** B2B lead generation: a lead list fills in row by row, each verified. */
|
||||
function LeadsArt({ reduce }: { reduce: boolean }) {
|
||||
const rows = [22, 48, 74];
|
||||
return (
|
||||
<motion.svg
|
||||
viewBox="0 0 240 96"
|
||||
className="h-auto w-full"
|
||||
initial={reduce ? undefined : "hidden"}
|
||||
whileInView="visible"
|
||||
viewport={VIEWPORT}
|
||||
>
|
||||
{rows.map((y, i) => (
|
||||
<motion.g
|
||||
key={y}
|
||||
variants={{ hidden: { opacity: 0, x: -10 }, visible: { opacity: 1, x: 0 } }}
|
||||
transition={{ duration: 0.35, ease: EASE_OUT, delay: 0.15 + i * 0.18 }}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<circle cx="24" cy={y} r="7" className="fill-muted-foreground/20" />
|
||||
{/* Name + contact lines */}
|
||||
<rect
|
||||
x="40"
|
||||
y={y - 8}
|
||||
width="92"
|
||||
height="6"
|
||||
rx="3"
|
||||
className="fill-muted-foreground/30"
|
||||
/>
|
||||
<rect
|
||||
x="40"
|
||||
y={y + 3}
|
||||
width="64"
|
||||
height="5"
|
||||
rx="2.5"
|
||||
className="fill-muted-foreground/15"
|
||||
/>
|
||||
{/* Verified check */}
|
||||
<motion.path
|
||||
d={`M 206 ${y - 1} l 4 5 l 8 -10`}
|
||||
fill="none"
|
||||
className="stroke-brand"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
variants={{
|
||||
hidden: { pathLength: 0, opacity: 0 },
|
||||
visible: { pathLength: 1, opacity: 1 },
|
||||
}}
|
||||
transition={{ duration: 0.3, ease: EASE_OUT, delay: 0.45 + i * 0.18 }}
|
||||
/>
|
||||
</motion.g>
|
||||
))}
|
||||
</motion.svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Market research: SERP rows; your result climbs from #3 to #1. */
|
||||
function SerpArt({ reduce }: { reduce: boolean }) {
|
||||
const swapDelay = 0.8;
|
||||
const swapY = { duration: 0.5, ease: EASE_OUT, delay: swapDelay, times: [0, 0.6, 1] };
|
||||
return (
|
||||
<motion.svg
|
||||
viewBox="0 0 240 96"
|
||||
className="h-auto w-full"
|
||||
initial={reduce ? undefined : "hidden"}
|
||||
whileInView="visible"
|
||||
viewport={VIEWPORT}
|
||||
>
|
||||
{/* Competitor rows: start at ranks 1 and 2, shift down one slot */}
|
||||
{[
|
||||
{ startY: 14, width: 148 },
|
||||
{ startY: 40, width: 120 },
|
||||
].map((row, i) => (
|
||||
<motion.g
|
||||
key={row.startY}
|
||||
style={reduce ? { y: 26 } : undefined}
|
||||
variants={
|
||||
reduce
|
||||
? undefined
|
||||
: { hidden: { opacity: 0, y: 0 }, visible: { opacity: 1, y: [0, 0, 26] } }
|
||||
}
|
||||
transition={
|
||||
reduce
|
||||
? undefined
|
||||
: { opacity: { duration: 0.35, ease: EASE_OUT, delay: 0.1 + i * 0.15 }, y: swapY }
|
||||
}
|
||||
>
|
||||
<rect
|
||||
x="34"
|
||||
y={row.startY}
|
||||
width={row.width}
|
||||
height="8"
|
||||
rx="4"
|
||||
className="fill-muted-foreground/25"
|
||||
/>
|
||||
<rect
|
||||
x="34"
|
||||
y={row.startY + 12}
|
||||
width={row.width * 0.6}
|
||||
height="5"
|
||||
rx="2.5"
|
||||
className="fill-muted-foreground/12"
|
||||
/>
|
||||
</motion.g>
|
||||
))}
|
||||
{/* Your row: starts at rank 3, climbs to rank 1 */}
|
||||
<motion.g
|
||||
style={reduce ? { y: -52 } : undefined}
|
||||
variants={
|
||||
reduce
|
||||
? undefined
|
||||
: { hidden: { opacity: 0, y: 0 }, visible: { opacity: 1, y: [0, 0, -52] } }
|
||||
}
|
||||
transition={
|
||||
reduce ? undefined : { opacity: { duration: 0.35, ease: EASE_OUT, delay: 0.4 }, y: swapY }
|
||||
}
|
||||
>
|
||||
<rect x="34" y="66" width="160" height="8" rx="4" className="fill-brand" />
|
||||
<rect x="34" y="78" width="96" height="5" rx="2.5" className="fill-brand/40" />
|
||||
<motion.path
|
||||
d="M 210 78 l 5 -7 l 5 7"
|
||||
fill="none"
|
||||
className="stroke-brand"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
variants={reduce ? undefined : { hidden: { opacity: 0 }, visible: { opacity: 1 } }}
|
||||
transition={{ duration: 0.3, ease: EASE_OUT, delay: swapDelay }}
|
||||
/>
|
||||
</motion.g>
|
||||
</motion.svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Founders & marketers: a plain-English ask streams back an agent brief with cited sources. */
|
||||
function ChatArt({ reduce }: { reduce: boolean }) {
|
||||
const briefLines = [
|
||||
{ y: 38, width: 150, delay: 0.55 },
|
||||
{ y: 52, width: 180, delay: 0.7 },
|
||||
{ y: 66, width: 120, delay: 0.85 },
|
||||
];
|
||||
return (
|
||||
<motion.svg
|
||||
viewBox="0 0 240 96"
|
||||
className="h-auto w-full"
|
||||
initial={reduce ? undefined : "hidden"}
|
||||
whileInView="visible"
|
||||
viewport={VIEWPORT}
|
||||
>
|
||||
{/* User ask: right-aligned bubble */}
|
||||
<motion.g
|
||||
variants={{ hidden: { opacity: 0, y: -6 }, visible: { opacity: 1, y: 0 } }}
|
||||
transition={{ duration: 0.35, ease: EASE_OUT, delay: 0.1 }}
|
||||
>
|
||||
<rect x="112" y="8" width="116" height="18" rx="9" className="fill-muted-foreground/15" />
|
||||
<rect x="124" y="14" width="92" height="6" rx="3" className="fill-muted-foreground/35" />
|
||||
</motion.g>
|
||||
{/* Agent avatar */}
|
||||
<motion.circle
|
||||
cx="22"
|
||||
cy="44"
|
||||
r="6"
|
||||
className="fill-brand"
|
||||
variants={{ hidden: { opacity: 0, scale: 0 }, visible: { opacity: 1, scale: 1 } }}
|
||||
style={{ transformBox: "fill-box", transformOrigin: "center" }}
|
||||
transition={{ duration: 0.3, ease: EASE_OUT, delay: 0.4 }}
|
||||
/>
|
||||
<Pulse cx={22} cy={44} reduce={reduce} delay={0.7} />
|
||||
{/* Brief streams in line by line */}
|
||||
{briefLines.map((line) => (
|
||||
<motion.rect
|
||||
key={line.y}
|
||||
x="36"
|
||||
y={line.y}
|
||||
width={line.width}
|
||||
height="6"
|
||||
rx="3"
|
||||
className="fill-muted-foreground/30"
|
||||
variants={{ hidden: { opacity: 0, x: -8 }, visible: { opacity: 1, x: 0 } }}
|
||||
transition={{ duration: 0.3, ease: EASE_OUT, delay: line.delay }}
|
||||
/>
|
||||
))}
|
||||
{/* Citation chip */}
|
||||
<motion.g
|
||||
variants={{ hidden: { opacity: 0, y: 6 }, visible: { opacity: 1, y: 0 } }}
|
||||
transition={{ duration: 0.3, ease: EASE_OUT, delay: 1.05 }}
|
||||
>
|
||||
<rect x="36" y="76" width="64" height="15" rx="7.5" className="fill-brand/10" />
|
||||
<text x="68" y="87" textAnchor="middle" className="fill-brand text-[9px] font-medium">
|
||||
3 sources
|
||||
</text>
|
||||
</motion.g>
|
||||
</motion.svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Developers & agents: a typed request gets a 200 and JSON streams back. */
|
||||
function ApiArt({ reduce }: { reduce: boolean }) {
|
||||
const jsonLines = [
|
||||
{ y: 50, text: '{ "items": [', delay: 0.6 },
|
||||
{ y: 66, text: ' { "title": "...", "score": 812 },', delay: 0.75 },
|
||||
{ y: 82, text: "] }", delay: 0.9 },
|
||||
];
|
||||
return (
|
||||
<motion.svg
|
||||
viewBox="0 0 240 96"
|
||||
className="h-auto w-full"
|
||||
initial={reduce ? undefined : "hidden"}
|
||||
whileInView="visible"
|
||||
viewport={VIEWPORT}
|
||||
>
|
||||
{/* Request line */}
|
||||
<motion.g
|
||||
variants={{ hidden: { opacity: 0, x: -8 }, visible: { opacity: 1, x: 0 } }}
|
||||
transition={{ duration: 0.35, ease: EASE_OUT, delay: 0.1 }}
|
||||
>
|
||||
<text x="12" y="22" className="fill-brand font-mono text-[10px] font-semibold">
|
||||
POST
|
||||
</text>
|
||||
<text x="44" y="22" className="fill-muted-foreground font-mono text-[10px]">
|
||||
/scrapers/reddit/scrape
|
||||
</text>
|
||||
</motion.g>
|
||||
{/* 200 OK badge */}
|
||||
<motion.g
|
||||
variants={{ hidden: { opacity: 0, scale: 0.8 }, visible: { opacity: 1, scale: 1 } }}
|
||||
style={{ transformBox: "fill-box", transformOrigin: "center" }}
|
||||
transition={{ duration: 0.3, ease: EASE_OUT, delay: 0.45 }}
|
||||
>
|
||||
<rect x="182" y="10" width="46" height="16" rx="4" className="fill-brand/10" />
|
||||
<text x="205" y="21" textAnchor="middle" className="fill-brand text-[9px] font-medium">
|
||||
200 OK
|
||||
</text>
|
||||
</motion.g>
|
||||
<line
|
||||
x1="12"
|
||||
y1="32"
|
||||
x2="228"
|
||||
y2="32"
|
||||
className="stroke-muted-foreground/15"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
{/* JSON response streams in */}
|
||||
{jsonLines.map((line) => (
|
||||
<motion.text
|
||||
key={line.y}
|
||||
x="12"
|
||||
y={line.y}
|
||||
className="fill-muted-foreground font-mono text-[10px]"
|
||||
style={{ whiteSpace: "pre" }}
|
||||
variants={{ hidden: { opacity: 0, x: -8 }, visible: { opacity: 1, x: 0 } }}
|
||||
transition={{ duration: 0.3, ease: EASE_OUT, delay: line.delay }}
|
||||
>
|
||||
{line.text}
|
||||
</motion.text>
|
||||
))}
|
||||
{/* Blinking cursor after the closing brace */}
|
||||
<motion.rect
|
||||
x="34"
|
||||
y="74"
|
||||
width="5"
|
||||
height="10"
|
||||
className="fill-brand"
|
||||
variants={{ hidden: { opacity: 0 }, visible: { opacity: 1 } }}
|
||||
animate={reduce ? undefined : { opacity: [1, 1, 0, 0] }}
|
||||
transition={
|
||||
reduce
|
||||
? { duration: 0.2, delay: 1 }
|
||||
: { duration: 1, times: [0, 0.5, 0.5, 1], repeat: Infinity, delay: 1 }
|
||||
}
|
||||
/>
|
||||
</motion.svg>
|
||||
);
|
||||
}
|
||||
|
||||
const ART: Record<UseCaseArtVariant, (props: { reduce: boolean }) => React.ReactNode> = {
|
||||
price: PriceArt,
|
||||
brand: BrandArt,
|
||||
leads: LeadsArt,
|
||||
serp: SerpArt,
|
||||
chat: ChatArt,
|
||||
api: ApiArt,
|
||||
};
|
||||
|
||||
/** Small animated artifact rendered at the top of each use-case card. */
|
||||
export function UseCaseArt({ variant }: { variant: UseCaseArtVariant }) {
|
||||
const reduce = useReducedMotion() ?? false;
|
||||
const Art = ART[variant];
|
||||
return (
|
||||
<div aria-hidden className="mb-4 rounded-lg border bg-muted/20 px-3 py-2">
|
||||
<Art reduce={reduce} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
87
surfsense_web/components/homepage/use-cases.tsx
Normal file
87
surfsense_web/components/homepage/use-cases.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { ArrowRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Reveal } from "@/components/connectors-marketing/reveal";
|
||||
import { UseCaseArt, type UseCaseArtVariant } from "@/components/homepage/use-case-art";
|
||||
import { MarketingSection } from "@/components/marketing/section";
|
||||
|
||||
/** Buyer language from the high-CPC keyword clusters; each anchors to the connector that fulfills it. */
|
||||
const USE_CASES: {
|
||||
title: string;
|
||||
description: string;
|
||||
href: string;
|
||||
anchor: string;
|
||||
art: UseCaseArtVariant;
|
||||
}[] = [
|
||||
{
|
||||
title: "Search & AI answer research",
|
||||
description:
|
||||
"Watch the rankings, ads, and AI Overviews people actually see for the queries you care about, and know the moment they change.",
|
||||
href: "/google-search",
|
||||
anchor: "SERP API",
|
||||
art: "serp",
|
||||
},
|
||||
{
|
||||
title: "Community & brand listening",
|
||||
description:
|
||||
"Track every mention of your brand, your competitors, and your category across the communities where people speak candidly.",
|
||||
href: "/reddit",
|
||||
anchor: "Reddit API",
|
||||
art: "brand",
|
||||
},
|
||||
{
|
||||
title: "Social sentiment mining",
|
||||
description:
|
||||
"Pull public posts, reels, and full comment threads from any creator or brand, then score how audiences actually react to launches and campaigns.",
|
||||
href: "/instagram",
|
||||
anchor: "Instagram API",
|
||||
art: "chat",
|
||||
},
|
||||
{
|
||||
title: "B2B lead generation",
|
||||
description:
|
||||
"Turn a category and a territory into a clean lead list with phones, websites, and ratings, ready for your CRM.",
|
||||
href: "/google-maps",
|
||||
anchor: "Google Maps API",
|
||||
art: "leads",
|
||||
},
|
||||
{
|
||||
title: "Competitor price monitoring",
|
||||
description:
|
||||
"Crawl competitor pricing and product pages on a schedule and get an alert the day something changes, not the quarter after.",
|
||||
href: "/web-crawl",
|
||||
anchor: "Web Crawl API",
|
||||
art: "price",
|
||||
},
|
||||
];
|
||||
|
||||
export function UseCasesRow() {
|
||||
return (
|
||||
<MarketingSection>
|
||||
<Reveal>
|
||||
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
What teams use SurfSense for
|
||||
</h2>
|
||||
</Reveal>
|
||||
<div className="mt-8 grid gap-6 sm:grid-cols-2">
|
||||
{USE_CASES.map((useCase) => (
|
||||
<Reveal key={useCase.title}>
|
||||
<div className="flex h-full flex-col rounded-xl border bg-card p-6">
|
||||
<UseCaseArt variant={useCase.art} />
|
||||
<h3 className="text-lg font-semibold">{useCase.title}</h3>
|
||||
<p className="mt-2 flex-1 text-sm leading-relaxed text-muted-foreground">
|
||||
{useCase.description}
|
||||
</p>
|
||||
<Link
|
||||
href={useCase.href}
|
||||
className="group mt-4 inline-flex items-center gap-1 text-sm font-medium text-foreground"
|
||||
>
|
||||
{useCase.anchor}
|
||||
<ArrowRight className="size-4 transition-transform group-hover:translate-x-0.5" />
|
||||
</Link>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</MarketingSection>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,426 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { IconPointerFilled } from "@tabler/icons-react";
|
||||
import { Check, X } from "lucide-react";
|
||||
import { motion, useInView } from "motion/react";
|
||||
import { useRef, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const cards = [
|
||||
{
|
||||
title: "Unlimited & Self-Hosted",
|
||||
description:
|
||||
"No caps on sources, notebooks, or file sizes. Deploy on your own infra and your data never leaves your control.",
|
||||
skeleton: <UnlimitedSkeleton />,
|
||||
},
|
||||
{
|
||||
title: "100+ LLMs, Zero Lock-in",
|
||||
description:
|
||||
"Swap between 100+ LLMs via OpenAI spec and LiteLLM, or run fully private with vLLM, Ollama, and more.",
|
||||
skeleton: <LLMFlexibilitySkeleton />,
|
||||
},
|
||||
{
|
||||
title: "Real-Time Multiplayer",
|
||||
description:
|
||||
"RBAC with Owner, Admin, Editor, and Viewer roles plus real-time chat and comment threads. Built for teams.",
|
||||
skeleton: <MultiplayerSkeleton />,
|
||||
},
|
||||
];
|
||||
|
||||
export function WhySurfSense() {
|
||||
return (
|
||||
<section className="max-w-7xl mx-auto my-10">
|
||||
<div className="mx-auto mb-10 text-center md:mb-16">
|
||||
<p className="mb-3 text-sm font-semibold uppercase tracking-widest text-brand">
|
||||
Why SurfSense
|
||||
</p>
|
||||
<h2 className="text-balance text-3xl font-bold tracking-tight text-foreground sm:text-4xl lg:text-5xl">
|
||||
Everything NotebookLM should have been
|
||||
</h2>
|
||||
<p className="mx-auto mt-4 max-w-2xl text-base text-muted-foreground">
|
||||
Open source. No data limits. No vendor lock-in. Built for teams that care about privacy
|
||||
and flexibility.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid mx-auto max-w-7xl grid-cols-1 divide-x-0 divide-y divide-border overflow-hidden rounded-2xl shadow-sm ring-1 ring-border md:grid-cols-3 md:divide-x md:divide-y-0">
|
||||
{cards.map((card) => (
|
||||
<FeatureCard key={card.title} {...card} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ComparisonStrip />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function UnlimitedSkeleton({ className }: { className?: string }) {
|
||||
const ref = useRef(null);
|
||||
const isInView = useInView(ref, { once: true, margin: "-50px" });
|
||||
|
||||
const items = [
|
||||
{ label: "Sources", notebookLm: "50-600", surfSense: "Unlimited", icon: "📄" },
|
||||
{ label: "Notebooks", notebookLm: "100-500", surfSense: "Unlimited", icon: "📓" },
|
||||
{ label: "File size", notebookLm: "200 MB", surfSense: "No limit", icon: "📦" },
|
||||
{ label: "Self-host", notebookLm: "No", surfSense: "Yes", icon: "🏠" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn("flex h-full flex-col justify-center gap-2.5", className)}>
|
||||
{items.map((item, index) => (
|
||||
<motion.div
|
||||
key={item.label}
|
||||
initial={{ opacity: 0, x: -16 }}
|
||||
animate={isInView ? { opacity: 1, x: 0 } : {}}
|
||||
transition={{ duration: 0.35, delay: index * 0.15 }}
|
||||
className="flex items-center gap-2 rounded-lg bg-background px-3 py-2 shadow-sm ring-1 ring-border"
|
||||
>
|
||||
<span className="text-sm">{item.icon}</span>
|
||||
<span className="min-w-[60px] text-xs font-medium text-foreground">{item.label}</span>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<span className="text-[10px] text-muted-foreground line-through">
|
||||
{item.notebookLm}
|
||||
</span>
|
||||
<motion.div
|
||||
initial={{ scale: 0.8 }}
|
||||
animate={isInView ? { scale: 1 } : {}}
|
||||
transition={{
|
||||
duration: 0.3,
|
||||
delay: index * 0.15 + 0.2,
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
}}
|
||||
>
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
|
||||
{item.surfSense}
|
||||
</Badge>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LLMFlexibilitySkeleton({ className }: { className?: string }) {
|
||||
const ref = useRef(null);
|
||||
const isInView = useInView(ref, { once: true, margin: "-50px" });
|
||||
const [selected, setSelected] = useState(0);
|
||||
|
||||
const models = [
|
||||
{ name: "GPT-4o", provider: "OpenAI", color: "bg-green-500" },
|
||||
{ name: "Claude 4", provider: "Anthropic", color: "bg-orange-500" },
|
||||
{ name: "Gemini 2.5", provider: "Google", color: "bg-blue-500" },
|
||||
{ name: "Llama 4", provider: "Local", color: "bg-purple-500" },
|
||||
{ name: "DeepSeek R1", provider: "DeepSeek", color: "bg-cyan-500" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex h-full flex-col items-center justify-center gap-3", className)}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="flex w-full max-w-[180px] flex-col gap-1.5"
|
||||
>
|
||||
{models.map((model, index) => (
|
||||
<motion.button
|
||||
key={model.name}
|
||||
type="button"
|
||||
onClick={() => setSelected(index)}
|
||||
initial={{ opacity: 0, x: 12 }}
|
||||
animate={isInView ? { opacity: 1, x: 0 } : {}}
|
||||
transition={{ duration: 0.3, delay: 0.1 + index * 0.1 }}
|
||||
className={cn(
|
||||
"flex w-full cursor-pointer items-center gap-2 rounded-lg px-2.5 py-1.5 text-left transition-all",
|
||||
selected === index ? "bg-background shadow-sm ring-1 ring-border" : "hover:bg-accent"
|
||||
)}
|
||||
>
|
||||
<div className={cn("size-2 shrink-0 rounded-full", model.color)} />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-xs font-medium text-foreground">{model.name}</p>
|
||||
<p className="text-[10px] text-muted-foreground">{model.provider}</p>
|
||||
</div>
|
||||
{selected === index && (
|
||||
<motion.div
|
||||
layoutId="model-check"
|
||||
className="ml-auto"
|
||||
transition={{ type: "spring", stiffness: 400, damping: 25 }}
|
||||
>
|
||||
<Check className="size-3 text-brand" />
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.button>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MultiplayerSkeleton({ className }: { className?: string }) {
|
||||
const ref = useRef(null);
|
||||
const isInView = useInView(ref, { once: true, margin: "-50px" });
|
||||
|
||||
const collaborators = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Alice",
|
||||
role: "Editor",
|
||||
color: "#3b82f6",
|
||||
path: [
|
||||
{ x: 15, y: 10 },
|
||||
{ x: 80, y: 40 },
|
||||
{ x: 40, y: 80 },
|
||||
{ x: 15, y: 10 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Bob",
|
||||
role: "Viewer",
|
||||
color: "#10b981",
|
||||
path: [
|
||||
{ x: 115, y: 70 },
|
||||
{ x: 55, y: 20 },
|
||||
{ x: 95, y: 50 },
|
||||
{ x: 115, y: 70 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const codeLines = [
|
||||
{ indent: 0, width: "60%", color: "bg-chart-4/60" },
|
||||
{ indent: 1, width: "75%", color: "bg-muted-foreground/20" },
|
||||
{ indent: 1, width: "50%", color: "bg-chart-1/60" },
|
||||
{ indent: 2, width: "80%", color: "bg-muted-foreground/20" },
|
||||
{ indent: 2, width: "45%", color: "bg-chart-2/60" },
|
||||
{ indent: 1, width: "30%", color: "bg-muted-foreground/20" },
|
||||
{ indent: 0, width: "20%", color: "bg-chart-4/60" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("relative flex h-full items-center justify-center overflow-visible", className)}
|
||||
>
|
||||
<motion.div
|
||||
className="relative w-full max-w-[160px] rounded-lg bg-background p-3 shadow-sm ring-1 ring-border"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
<div className="flex gap-1">
|
||||
<div className="size-1.5 rounded-full bg-red-400" />
|
||||
<div className="size-1.5 rounded-full bg-yellow-400" />
|
||||
<div className="size-1.5 rounded-full bg-green-400" />
|
||||
</div>
|
||||
<div className="ml-2 h-1.5 w-12 rounded-full bg-muted" />
|
||||
</div>
|
||||
|
||||
{codeLines.map((line, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="my-1.5 flex items-center"
|
||||
style={{ paddingLeft: line.indent * 8 }}
|
||||
>
|
||||
<div className={cn("h-1.5 rounded-full", line.color)} style={{ width: line.width }} />
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{collaborators.map((collaborator, index) => (
|
||||
<motion.div
|
||||
key={collaborator.id}
|
||||
className="absolute"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={
|
||||
isInView
|
||||
? {
|
||||
opacity: 1,
|
||||
x: collaborator.path.map((p) => p.x),
|
||||
y: collaborator.path.map((p) => p.y),
|
||||
}
|
||||
: {}
|
||||
}
|
||||
transition={{
|
||||
opacity: { duration: 0.3, delay: 0.5 + index * 0.2 },
|
||||
x: {
|
||||
duration: 6,
|
||||
delay: 0.5 + index * 0.3,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
},
|
||||
y: {
|
||||
duration: 6,
|
||||
delay: 0.5 + index * 0.3,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<IconPointerFilled
|
||||
className="size-5 drop-shadow-sm"
|
||||
style={{ color: collaborator.color }}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-5 left-3 z-50 flex w-max items-center gap-1.5 rounded-full py-1 pr-2.5 pl-1 shadow-sm"
|
||||
style={{ backgroundColor: collaborator.color }}
|
||||
>
|
||||
<div className="flex size-5 items-center justify-center rounded-full bg-white/20 text-[9px] font-bold text-white">
|
||||
{collaborator.name[0]}
|
||||
</div>
|
||||
<span className="shrink-0 text-[10px] font-medium text-white">{collaborator.name}</span>
|
||||
<span className="rounded bg-white/20 px-1 py-px text-[8px] text-white/80">
|
||||
{collaborator.role}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FeatureCard({
|
||||
title,
|
||||
description,
|
||||
skeleton,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
skeleton: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full flex-col justify-between bg-card p-10 first:rounded-l-2xl last:rounded-r-2xl">
|
||||
<div className="h-60 w-full overflow-visible rounded-md">{skeleton}</div>
|
||||
<div className="mt-4">
|
||||
<h3 className="text-base font-bold tracking-tight text-card-foreground">{title}</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed tracking-tight text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const comparisonRows: {
|
||||
feature: string;
|
||||
notebookLm: string | boolean;
|
||||
surfSense: string | boolean;
|
||||
}[] = [
|
||||
{
|
||||
feature: "Sources per Notebook",
|
||||
notebookLm: "50-600",
|
||||
surfSense: "Unlimited",
|
||||
},
|
||||
{
|
||||
feature: "LLM Support",
|
||||
notebookLm: "Gemini only",
|
||||
surfSense: "100+ LLMs",
|
||||
},
|
||||
{
|
||||
feature: "Self-Hostable",
|
||||
notebookLm: false,
|
||||
surfSense: true,
|
||||
},
|
||||
{
|
||||
feature: "Open Source",
|
||||
notebookLm: false,
|
||||
surfSense: true,
|
||||
},
|
||||
{
|
||||
feature: "External Connectors",
|
||||
notebookLm: "Limited",
|
||||
surfSense: "27+",
|
||||
},
|
||||
{
|
||||
feature: "Desktop App",
|
||||
notebookLm: false,
|
||||
surfSense: true,
|
||||
},
|
||||
{
|
||||
feature: "Agentic Architecture",
|
||||
notebookLm: false,
|
||||
surfSense: true,
|
||||
},
|
||||
{
|
||||
feature: "AI Automations & Agents",
|
||||
notebookLm: false,
|
||||
surfSense: "Scheduled & event-triggered",
|
||||
},
|
||||
{
|
||||
feature: "AI File Sorting",
|
||||
notebookLm: false,
|
||||
surfSense: true,
|
||||
},
|
||||
];
|
||||
|
||||
function ComparisonStrip() {
|
||||
const ref = useRef(null);
|
||||
const isInView = useInView(ref, { once: true, margin: "-80px" });
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.5, delay: 0.1 }}
|
||||
className="mx-auto mt-12 max-w-7xl overflow-hidden rounded-2xl bg-card shadow-sm ring-1 ring-border"
|
||||
>
|
||||
<div className="grid grid-cols-3 px-4 py-3 sm:px-6">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Feature
|
||||
</span>
|
||||
<span className="text-center text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
NotebookLM
|
||||
</span>
|
||||
<span className="text-center text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
SurfSense
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{comparisonRows.map((row, index) => (
|
||||
<motion.div
|
||||
key={row.feature}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={isInView ? { opacity: 1, x: 0 } : {}}
|
||||
transition={{ duration: 0.3, delay: 0.15 + index * 0.06 }}
|
||||
>
|
||||
<div className="grid grid-cols-3 items-center px-4 py-2.5 text-sm sm:px-6">
|
||||
<span className="font-medium text-card-foreground">{row.feature}</span>
|
||||
<span className="flex justify-center">
|
||||
{typeof row.notebookLm === "boolean" ? (
|
||||
row.notebookLm ? (
|
||||
<Check className="size-4 text-brand" />
|
||||
) : (
|
||||
<X className="size-4 text-muted-foreground/40" />
|
||||
)
|
||||
) : (
|
||||
<span className="text-muted-foreground">{row.notebookLm}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="flex justify-center">
|
||||
{typeof row.surfSense === "boolean" ? (
|
||||
row.surfSense ? (
|
||||
<Check className="size-4 text-brand" />
|
||||
) : (
|
||||
<X className="size-4 text-muted-foreground/40" />
|
||||
)
|
||||
) : (
|
||||
<Badge variant="secondary">{row.surfSense}</Badge>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{index !== comparisonRows.length - 1 && <Separator />}
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
|
@ -27,6 +27,7 @@ export { default as PerplexityIcon } from "./perplexity.svg";
|
|||
export { default as QwenIcon } from "./qwen.svg";
|
||||
export { default as RecraftIcon } from "./recraft.svg";
|
||||
export { default as ReplicateIcon } from "./replicate.svg";
|
||||
export { default as RequestyIcon } from "./requesty.svg";
|
||||
export { default as SambaNovaIcon } from "./sambanova.svg";
|
||||
export { default as TogetherAiIcon } from "./togetherai.svg";
|
||||
export { default as VertexAiIcon } from "./vertexai.svg";
|
||||
|
|
|
|||
1
surfsense_web/components/icons/providers/requesty.svg
Normal file
1
surfsense_web/components/icons/providers/requesty.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg fill="currentColor" fill-rule="evenodd" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 2a1 1 0 000 2h2.086l-5.293 5.293a1 1 0 001.414 1.414L18 5.414V7.5a1 1 0 102 0v-4.5a1 1 0 00-1-1h-4.5zM4 6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4a1 1 0 10-2 0v4H4V8h4a1 1 0 000-2H4z"></path></svg>
|
||||
|
After Width: | Height: | Size: 318 B |
|
|
@ -5,14 +5,19 @@ export type {
|
|||
IconRailProps,
|
||||
NavItem,
|
||||
PageUsage,
|
||||
SearchSpace,
|
||||
SidebarSectionProps,
|
||||
User,
|
||||
Workspace,
|
||||
} from "./types/layout.types";
|
||||
export type { RoutedSectionGroup, RoutedSectionItem } from "./ui";
|
||||
export {
|
||||
ChatListItem,
|
||||
CreateSearchSpaceDialog,
|
||||
CreateWorkspaceDialog,
|
||||
CreditBalanceDisplay,
|
||||
getPlaygroundActiveValue,
|
||||
getPlaygroundNavGroups,
|
||||
getPlaygroundNavItems,
|
||||
getPlaygroundSelectedLabel,
|
||||
Header,
|
||||
IconRail,
|
||||
LayoutShell,
|
||||
|
|
@ -20,10 +25,12 @@ export {
|
|||
MobileSidebarTrigger,
|
||||
NavIcon,
|
||||
NavSection,
|
||||
SearchSpaceAvatar,
|
||||
PlaygroundSidebar,
|
||||
RoutedSectionShell,
|
||||
Sidebar,
|
||||
SidebarCollapseButton,
|
||||
SidebarHeader,
|
||||
SidebarSection,
|
||||
SidebarUserProfile,
|
||||
WorkspaceAvatar,
|
||||
} from "./ui";
|
||||
|
|
|
|||
|
|
@ -1,22 +1,20 @@
|
|||
"use client";
|
||||
|
||||
import { Inbox, LibraryBig } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useAnonymousMode } from "@/contexts/anonymous-mode";
|
||||
import { useLoginGate } from "@/contexts/login-gate";
|
||||
import { useAnnouncements } from "@/hooks/use-announcements";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service";
|
||||
import type { ChatItem, NavItem, PageUsage, SearchSpace } from "../types/layout.types";
|
||||
import type { ChatItem, PageUsage, Workspace } from "../types/layout.types";
|
||||
import { LayoutShell } from "../ui/shell";
|
||||
|
||||
interface FreeLayoutDataProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const GUEST_SPACE: SearchSpace = {
|
||||
const GUEST_SPACE: Workspace = {
|
||||
id: 0,
|
||||
name: "SurfSense Free",
|
||||
description: "Free AI chat without login",
|
||||
|
|
@ -28,15 +26,8 @@ export function FreeLayoutDataProvider({ children }: FreeLayoutDataProviderProps
|
|||
const router = useRouter();
|
||||
const { gate } = useLoginGate();
|
||||
const anonMode = useAnonymousMode();
|
||||
const isMobile = useIsMobile();
|
||||
const { unreadCount: announcementUnreadCount } = useAnnouncements();
|
||||
const [quota, setQuota] = useState<{ used: number; limit: number } | null>(null);
|
||||
const [isDocsSidebarOpen, setIsDocsSidebarOpen] = useState(false);
|
||||
|
||||
// Keep docs sidebar closed on mobile; auto-open only on desktop after hydration
|
||||
useEffect(() => {
|
||||
setIsDocsSidebarOpen(!isMobile);
|
||||
}, [isMobile]);
|
||||
|
||||
useEffect(() => {
|
||||
anonymousChatApiService
|
||||
|
|
@ -55,60 +46,25 @@ export function FreeLayoutDataProvider({ children }: FreeLayoutDataProviderProps
|
|||
|
||||
const gatedAction = useCallback((feature: string) => () => gate(feature), [gate]);
|
||||
|
||||
const navItems: NavItem[] = useMemo(
|
||||
() =>
|
||||
(
|
||||
[
|
||||
{
|
||||
title: "Inbox",
|
||||
url: "#inbox",
|
||||
icon: Inbox,
|
||||
isActive: false,
|
||||
},
|
||||
isMobile
|
||||
? {
|
||||
title: "Documents",
|
||||
url: "#documents",
|
||||
icon: LibraryBig,
|
||||
isActive: false,
|
||||
}
|
||||
: null,
|
||||
] as (NavItem | null)[]
|
||||
).filter((item): item is NavItem => item !== null),
|
||||
[isMobile]
|
||||
);
|
||||
|
||||
const pageUsage: PageUsage | undefined = quota
|
||||
? { pagesUsed: quota.used, pagesLimit: quota.limit }
|
||||
: undefined;
|
||||
|
||||
const handleChatSelect = useCallback((_chat: ChatItem) => gate("view chat history"), [gate]);
|
||||
|
||||
const handleNavItemClick = useCallback(
|
||||
(item: NavItem) => {
|
||||
if (item.title === "Inbox") gate("use the inbox");
|
||||
else if (item.title === "Documents") setIsDocsSidebarOpen((v) => !v);
|
||||
},
|
||||
[gate]
|
||||
);
|
||||
|
||||
const handleAnnouncements = useCallback(() => gate("see what's new"), [gate]);
|
||||
|
||||
const handleSearchSpaceSelect = useCallback(
|
||||
(_id: number) => gate("switch search spaces"),
|
||||
[gate]
|
||||
);
|
||||
const handleWorkspaceSelect = useCallback((_id: number) => gate("switch workspaces"), [gate]);
|
||||
|
||||
return (
|
||||
<LayoutShell
|
||||
searchSpaces={[GUEST_SPACE]}
|
||||
activeSearchSpaceId={0}
|
||||
onSearchSpaceSelect={handleSearchSpaceSelect}
|
||||
onSearchSpaceSettings={gatedAction("search space settings")}
|
||||
onAddSearchSpace={gatedAction("create search spaces")}
|
||||
searchSpace={GUEST_SPACE}
|
||||
navItems={navItems}
|
||||
onNavItemClick={handleNavItemClick}
|
||||
workspaces={[GUEST_SPACE]}
|
||||
activeWorkspaceId={0}
|
||||
onWorkspaceSelect={handleWorkspaceSelect}
|
||||
onWorkspaceSettings={gatedAction("workspace settings")}
|
||||
onAddWorkspace={gatedAction("create workspaces")}
|
||||
workspace={GUEST_SPACE}
|
||||
navItems={[]}
|
||||
chats={[]}
|
||||
activeChatId={null}
|
||||
onNewChat={resetChat}
|
||||
|
|
@ -121,7 +77,7 @@ export function FreeLayoutDataProvider({ children }: FreeLayoutDataProviderProps
|
|||
email: "Guest",
|
||||
name: "Guest",
|
||||
}}
|
||||
onSettings={gatedAction("search space settings")}
|
||||
onSettings={gatedAction("workspace settings")}
|
||||
onManageMembers={gatedAction("team management")}
|
||||
onUserSettings={gatedAction("account settings")}
|
||||
onAnnouncements={handleAnnouncements}
|
||||
|
|
@ -129,11 +85,8 @@ export function FreeLayoutDataProvider({ children }: FreeLayoutDataProviderProps
|
|||
onLogout={() => router.push("/register")}
|
||||
pageUsage={pageUsage}
|
||||
isChatPage
|
||||
showTabs={false}
|
||||
isLoadingChats={false}
|
||||
documentsPanel={{
|
||||
open: isDocsSidebarOpen,
|
||||
onOpenChange: setIsDocsSidebarOpen,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</LayoutShell>
|
||||
|
|
|
|||
|
|
@ -1,25 +1,24 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { AlarmClock, AlertTriangle, Boxes, Inbox, LibraryBig } from "lucide-react";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { AlarmClock, AlertTriangle, Shapes, SquareTerminal, Unplug } from "lucide-react";
|
||||
import { useParams, usePathname, useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { currentThreadAtom, resetCurrentThreadAtom } from "@/atoms/chat/current-thread.atom";
|
||||
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
|
||||
import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
|
||||
import { announcementsDialogAtom } from "@/atoms/layout/dialogs.atom";
|
||||
import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom";
|
||||
import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
|
||||
import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { removeChatTabAtom, syncChatTabAtom, type Tab } from "@/atoms/tabs/tabs.atom";
|
||||
import { removeChatTabAtom, syncChatTabAtom } from "@/atoms/tabs/tabs.atom";
|
||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
import { deleteWorkspaceMutationAtom } from "@/atoms/workspaces/workspace-mutation.atoms";
|
||||
import { workspaceLimitsAtom, workspacesAtom } from "@/atoms/workspaces/workspace-query.atoms";
|
||||
import { ActionLogDialog } from "@/components/agent-action-log/action-log-dialog";
|
||||
import { AnnouncementSpotlight } from "@/components/announcements/AnnouncementSpotlight";
|
||||
import { AnnouncementsDialog } from "@/components/announcements/AnnouncementsDialog";
|
||||
import { ActiveChatStreamRunner } from "@/components/chat/active-chat-stream-runner";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
|
|
@ -44,35 +43,30 @@ import { Spinner } from "@/components/ui/spinner";
|
|||
import { useActivateChatThread } from "@/hooks/use-activate-chat-thread";
|
||||
import { useAnnouncements } from "@/hooks/use-announcements";
|
||||
import { useInbox } from "@/hooks/use-inbox";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { getChatUrl, type ResolvedTab } from "@/hooks/use-resolved-tabs";
|
||||
import { useArchiveThread, useDeleteThread, useRenameThread } from "@/hooks/use-thread-mutations";
|
||||
import { notificationsApiService } from "@/lib/apis/notifications-api.service";
|
||||
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||
import { workspacesApiService } from "@/lib/apis/workspaces-api.service";
|
||||
import { getLoginPath, logout } from "@/lib/auth-utils";
|
||||
import { fetchThreads } from "@/lib/chat/thread-persistence";
|
||||
import { resetUser, trackLogout } from "@/lib/posthog/events";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import type { ChatItem, NavItem, SearchSpace } from "../types/layout.types";
|
||||
import { CreateSearchSpaceDialog } from "../ui/dialogs";
|
||||
import type { ChatItem, NavItem, Workspace } from "../types/layout.types";
|
||||
import { CreateWorkspaceDialog } from "../ui/dialogs";
|
||||
import { PlaygroundSidebar } from "../ui/playground/PlaygroundSidebar";
|
||||
import { LayoutShell } from "../ui/shell";
|
||||
|
||||
interface LayoutDataProviderProps {
|
||||
searchSpaceId: string;
|
||||
workspaceId: string;
|
||||
initialPlaygroundSidebarCollapsed: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format count for display: shows numbers up to 999, then "1k+", "2k+", etc.
|
||||
*/
|
||||
function formatInboxCount(count: number): string {
|
||||
if (count <= 999) {
|
||||
return count.toString();
|
||||
}
|
||||
const thousands = Math.floor(count / 1000);
|
||||
return `${thousands}k+`;
|
||||
}
|
||||
|
||||
export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProviderProps) {
|
||||
export function LayoutDataProvider({
|
||||
workspaceId,
|
||||
initialPlaygroundSidebarCollapsed,
|
||||
children,
|
||||
}: LayoutDataProviderProps) {
|
||||
const t = useTranslations("dashboard");
|
||||
const tCommon = useTranslations("common");
|
||||
const tSidebar = useTranslations("sidebar");
|
||||
|
|
@ -80,7 +74,6 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
const params = useParams();
|
||||
const pathname = usePathname();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// Announcements
|
||||
const { unreadCount: announcementUnreadCount } = useAnnouncements();
|
||||
|
|
@ -88,19 +81,20 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
// Atoms
|
||||
const { data: user } = useAtomValue(currentUserAtom);
|
||||
const {
|
||||
data: searchSpacesData,
|
||||
refetch: refetchSearchSpaces,
|
||||
isSuccess: searchSpacesLoaded,
|
||||
} = useAtomValue(searchSpacesAtom);
|
||||
const { mutateAsync: deleteSearchSpace } = useAtomValue(deleteSearchSpaceMutationAtom);
|
||||
data: workspacesData,
|
||||
refetch: refetchWorkspaces,
|
||||
isSuccess: workspacesLoaded,
|
||||
} = useAtomValue(workspacesAtom);
|
||||
const { data: workspaceLimits } = useAtomValue(workspaceLimitsAtom);
|
||||
const { mutateAsync: deleteWorkspace } = useAtomValue(deleteWorkspaceMutationAtom);
|
||||
const currentThreadState = useAtomValue(currentThreadAtom);
|
||||
const resetCurrentThread = useSetAtom(resetCurrentThreadAtom);
|
||||
const syncChatTab = useSetAtom(syncChatTabAtom);
|
||||
const removeChatTab = useSetAtom(removeChatTabAtom);
|
||||
const { activateChatThread, prefetchChatThread } = useActivateChatThread();
|
||||
const { mutateAsync: archiveThread } = useArchiveThread(searchSpaceId);
|
||||
const { mutateAsync: deleteThread } = useDeleteThread(searchSpaceId);
|
||||
const { mutateAsync: renameThread } = useRenameThread(searchSpaceId);
|
||||
const { mutateAsync: archiveThread } = useArchiveThread(workspaceId);
|
||||
const { mutateAsync: deleteThread } = useDeleteThread(workspaceId);
|
||||
const { mutateAsync: renameThread } = useRenameThread(workspaceId);
|
||||
|
||||
// Key used to force-remount the page component (e.g. after deleting the active chat
|
||||
// when the router is out of sync due to replaceState)
|
||||
|
|
@ -111,47 +105,25 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
? Number(Array.isArray(params.chat_id) ? params.chat_id[0] : params.chat_id)
|
||||
: currentThreadState.id;
|
||||
|
||||
// Fetch current search space (for caching purposes)
|
||||
useQuery({
|
||||
queryKey: cacheKeys.searchSpaces.detail(searchSpaceId),
|
||||
queryFn: () => searchSpacesApiService.getSearchSpace({ id: Number(searchSpaceId) }),
|
||||
enabled: !!searchSpaceId,
|
||||
// Fetch current workspace as a fallback for the selector while the full list catches up.
|
||||
const { data: currentWorkspace } = useQuery({
|
||||
queryKey: cacheKeys.workspaces.detail(workspaceId),
|
||||
queryFn: () => workspacesApiService.getWorkspace({ id: Number(workspaceId) }),
|
||||
enabled: !!workspaceId,
|
||||
});
|
||||
|
||||
// Fetch threads (40 total to allow up to 20 per section - shared/private)
|
||||
// Fetch recent threads for the sidebar.
|
||||
const { data: threadsData, isPending: isLoadingThreads } = useQuery({
|
||||
queryKey: ["threads", searchSpaceId, { limit: 40 }],
|
||||
queryFn: () => fetchThreads(Number(searchSpaceId), 40),
|
||||
enabled: !!searchSpaceId,
|
||||
queryKey: ["threads", workspaceId, { limit: 6 }],
|
||||
queryFn: () => fetchThreads(Number(workspaceId), 6),
|
||||
enabled: !!workspaceId,
|
||||
});
|
||||
|
||||
// Unified slide-out panel state (only one can be open at a time)
|
||||
type SlideoutPanel = "inbox" | "chats" | null;
|
||||
const [activeSlideoutPanel, setActiveSlideoutPanel] = useState<SlideoutPanel>(null);
|
||||
|
||||
const isInboxSidebarOpen = activeSlideoutPanel === "inbox";
|
||||
|
||||
// Documents sidebar state (shared atom so Composer can toggle it)
|
||||
const [isDocumentsSidebarOpen, setIsDocumentsSidebarOpen] = useAtom(documentsSidebarOpenAtom);
|
||||
const setIsRightPanelCollapsed = useSetAtom(rightPanelCollapsedAtom);
|
||||
|
||||
// Open documents sidebar by default on desktop (docked mode)
|
||||
const documentsInitialized = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!documentsInitialized.current) {
|
||||
documentsInitialized.current = true;
|
||||
const isDesktop = typeof window !== "undefined" && window.innerWidth >= 768;
|
||||
if (isDesktop) {
|
||||
setIsDocumentsSidebarOpen(true);
|
||||
}
|
||||
}
|
||||
}, [setIsDocumentsSidebarOpen]);
|
||||
|
||||
// Search space dialog state
|
||||
const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false);
|
||||
const [isCreateWorkspaceDialogOpen, setIsCreateWorkspaceDialogOpen] = useState(false);
|
||||
|
||||
const userId = user?.id ? String(user.id) : null;
|
||||
const numericSpaceId = Number(searchSpaceId) || null;
|
||||
const numericSpaceId = Number(workspaceId) || null;
|
||||
|
||||
// Batch-fetch unread counts for all categories in a single request
|
||||
// instead of 2 separate /unread-count calls.
|
||||
|
|
@ -219,11 +191,11 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
icon: <AlertTriangle className="h-5 w-5 text-amber-500" />,
|
||||
action: {
|
||||
label: "Buy credits",
|
||||
onClick: () => router.push(`/dashboard/${searchSpaceId}/buy-more`),
|
||||
onClick: () => router.push(`/dashboard/${workspaceId}/buy-more`),
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [statusInbox.inboxItems, statusInbox.loading, searchSpaceId, router]);
|
||||
}, [statusInbox.inboxItems, statusInbox.loading, workspaceId, router]);
|
||||
|
||||
// Delete dialogs state
|
||||
const [showDeleteChatDialog, setShowDeleteChatDialog] = useState(false);
|
||||
|
|
@ -236,28 +208,17 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
const [newChatTitle, setNewChatTitle] = useState("");
|
||||
const [isRenamingChat, setIsRenamingChat] = useState(false);
|
||||
|
||||
// Delete/Leave search space dialog state
|
||||
const [showDeleteSearchSpaceDialog, setShowDeleteSearchSpaceDialog] = useState(false);
|
||||
const [showLeaveSearchSpaceDialog, setShowLeaveSearchSpaceDialog] = useState(false);
|
||||
const [searchSpaceToDelete, setSearchSpaceToDelete] = useState<SearchSpace | null>(null);
|
||||
const [searchSpaceToLeave, setSearchSpaceToLeave] = useState<SearchSpace | null>(null);
|
||||
const [isDeletingSearchSpace, setIsDeletingSearchSpace] = useState(false);
|
||||
const [isLeavingSearchSpace, setIsLeavingSearchSpace] = useState(false);
|
||||
// Delete/Leave workspace dialog state
|
||||
const [showDeleteWorkspaceDialog, setShowDeleteWorkspaceDialog] = useState(false);
|
||||
const [showLeaveWorkspaceDialog, setShowLeaveWorkspaceDialog] = useState(false);
|
||||
const [workspaceToDelete, setWorkspaceToDelete] = useState<Workspace | null>(null);
|
||||
const [workspaceToLeave, setWorkspaceToLeave] = useState<Workspace | null>(null);
|
||||
const [isDeletingWorkspace, setIsDeletingWorkspace] = useState(false);
|
||||
const [isLeavingWorkspace, setIsLeavingWorkspace] = useState(false);
|
||||
|
||||
// Reset transient slide-out panels when switching search spaces.
|
||||
// Tabs intentionally persist across spaces — opening tabs from multiple
|
||||
// search spaces is a supported flow (browser-tab semantics).
|
||||
const prevSearchSpaceIdRef = useRef(searchSpaceId);
|
||||
useEffect(() => {
|
||||
if (prevSearchSpaceIdRef.current !== searchSpaceId) {
|
||||
prevSearchSpaceIdRef.current = searchSpaceId;
|
||||
setActiveSlideoutPanel(null);
|
||||
}
|
||||
}, [searchSpaceId]);
|
||||
|
||||
const searchSpaces: SearchSpace[] = useMemo(() => {
|
||||
if (!searchSpacesData || !Array.isArray(searchSpacesData)) return [];
|
||||
return searchSpacesData.map((space) => ({
|
||||
const workspaces: Workspace[] = useMemo(() => {
|
||||
if (!workspacesData || !Array.isArray(workspacesData)) return [];
|
||||
return workspacesData.map((space) => ({
|
||||
id: space.id,
|
||||
name: space.name,
|
||||
description: space.description,
|
||||
|
|
@ -265,165 +226,171 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
memberCount: space.member_count || 0,
|
||||
createdAt: space.created_at,
|
||||
}));
|
||||
}, [searchSpacesData]);
|
||||
}, [workspacesData]);
|
||||
const maxWorkspacesPerUser = workspaceLimits?.max_workspaces_per_user;
|
||||
const ownedWorkspaceCount = workspaces.reduce(
|
||||
(count, space) => count + (space.isOwner ? 1 : 0),
|
||||
0
|
||||
);
|
||||
const isAtWorkspaceLimit =
|
||||
maxWorkspacesPerUser !== undefined && ownedWorkspaceCount >= maxWorkspacesPerUser;
|
||||
|
||||
// Find active search space from list (has is_owner and member_count)
|
||||
const activeSearchSpace: SearchSpace | null = useMemo(() => {
|
||||
if (!searchSpaceId || !searchSpaces.length) return null;
|
||||
return searchSpaces.find((s) => s.id === Number(searchSpaceId)) ?? null;
|
||||
}, [searchSpaceId, searchSpaces]);
|
||||
// Find active workspace from list, falling back to the route-scoped detail query.
|
||||
const activeWorkspace: Workspace | null = useMemo(() => {
|
||||
if (!workspaceId) return null;
|
||||
const workspaceIdNumber = Number(workspaceId);
|
||||
const listedSpace = workspaces.find((s) => s.id === workspaceIdNumber);
|
||||
if (listedSpace) return listedSpace;
|
||||
if (!currentWorkspace || currentWorkspace.id !== workspaceIdNumber) return null;
|
||||
return {
|
||||
id: currentWorkspace.id,
|
||||
name: currentWorkspace.name,
|
||||
description: currentWorkspace.description,
|
||||
isOwner: false,
|
||||
memberCount: 0,
|
||||
createdAt: currentWorkspace.created_at,
|
||||
};
|
||||
}, [currentWorkspace, workspaceId, workspaces]);
|
||||
|
||||
// Safety redirect: if the current search space is no longer in the user's list
|
||||
// Safety redirect: if the current workspace is no longer in the user's list
|
||||
// (e.g. deleted by background task, membership revoked), redirect to a valid space.
|
||||
useEffect(() => {
|
||||
if (!searchSpacesLoaded || !searchSpaceId || isDeletingSearchSpace || isLeavingSearchSpace)
|
||||
return;
|
||||
if (searchSpaces.length > 0 && !activeSearchSpace) {
|
||||
router.replace(`/dashboard/${searchSpaces[0].id}/new-chat`);
|
||||
} else if (searchSpaces.length === 0 && searchSpacesLoaded) {
|
||||
if (!workspacesLoaded || !workspaceId || isDeletingWorkspace || isLeavingWorkspace) return;
|
||||
if (workspaces.length > 0 && !activeWorkspace) {
|
||||
router.replace(`/dashboard/${workspaces[0].id}/new-chat`);
|
||||
} else if (workspaces.length === 0 && workspacesLoaded && !activeWorkspace) {
|
||||
router.replace("/dashboard");
|
||||
}
|
||||
}, [
|
||||
searchSpacesLoaded,
|
||||
searchSpaceId,
|
||||
searchSpaces,
|
||||
activeSearchSpace,
|
||||
isDeletingSearchSpace,
|
||||
isLeavingSearchSpace,
|
||||
workspacesLoaded,
|
||||
workspaceId,
|
||||
workspaces,
|
||||
activeWorkspace,
|
||||
isDeletingWorkspace,
|
||||
isLeavingWorkspace,
|
||||
router,
|
||||
]);
|
||||
|
||||
// Sync current chat route with tab state
|
||||
useEffect(() => {
|
||||
const chatId = currentChatId ?? null;
|
||||
const chatUrl = chatId
|
||||
? `/dashboard/${searchSpaceId}/new-chat/${chatId}`
|
||||
: `/dashboard/${searchSpaceId}/new-chat`;
|
||||
const thread = threadsData?.threads?.find((t) => t.id === chatId);
|
||||
syncChatTab({
|
||||
chatId,
|
||||
// Avoid overwriting live SSE-updated tab titles with fallback values.
|
||||
title: chatId ? (thread?.title ?? undefined) : "New Chat",
|
||||
chatUrl,
|
||||
searchSpaceId: Number(searchSpaceId),
|
||||
...(thread?.visibility !== undefined ? { visibility: thread.visibility } : {}),
|
||||
workspaceId: Number(workspaceId),
|
||||
});
|
||||
}, [currentChatId, searchSpaceId, threadsData?.threads, syncChatTab]);
|
||||
}, [currentChatId, workspaceId, syncChatTab]);
|
||||
|
||||
const chats = useMemo(() => {
|
||||
if (!threadsData?.threads) return [];
|
||||
|
||||
return threadsData.threads.map<ChatItem>((thread) => ({
|
||||
id: thread.id,
|
||||
name: thread.title || `Chat ${thread.id}`,
|
||||
url: `/dashboard/${searchSpaceId}/new-chat/${thread.id}`,
|
||||
name: thread.title || "New Chat",
|
||||
url: `/dashboard/${workspaceId}/new-chat/${thread.id}`,
|
||||
visibility: thread.visibility,
|
||||
isOwnThread: thread.is_own_thread,
|
||||
archived: thread.archived,
|
||||
}));
|
||||
}, [threadsData, searchSpaceId]);
|
||||
}, [threadsData, workspaceId]);
|
||||
|
||||
// Navigation items
|
||||
// Inbox, Automations, and Documents are rendered explicitly below "New chat"
|
||||
// in the sidebar (also surfaced in the icon rail's collapsed mode via this
|
||||
// list). Announcements has been moved to the avatar dropdown.
|
||||
// Automations and Artifacts are rendered explicitly below "New chat"
|
||||
// in the sidebar. Documents is embedded below Recents; notifications and
|
||||
// announcements live in the avatar rail/dropdown.
|
||||
const isAutomationsActive = pathname?.includes("/automations") === true;
|
||||
const isArtifactsActive = pathname?.endsWith("/artifacts") === true;
|
||||
const isPlaygroundRoute = pathname?.includes("/playground") === true;
|
||||
const isConnectorsRoute = pathname?.includes("/connectors") === true;
|
||||
const navItems: NavItem[] = useMemo(
|
||||
() =>
|
||||
(
|
||||
[
|
||||
{
|
||||
title: "Inbox",
|
||||
url: "#inbox",
|
||||
icon: Inbox,
|
||||
isActive: isInboxSidebarOpen,
|
||||
badge: totalUnreadCount > 0 ? formatInboxCount(totalUnreadCount) : undefined,
|
||||
},
|
||||
{
|
||||
title: "Automations",
|
||||
url: `/dashboard/${searchSpaceId}/automations`,
|
||||
url: `/dashboard/${workspaceId}/automations`,
|
||||
icon: AlarmClock,
|
||||
isActive: isAutomationsActive,
|
||||
},
|
||||
{
|
||||
title: "Artifacts",
|
||||
url: `/dashboard/${searchSpaceId}/artifacts`,
|
||||
icon: Boxes,
|
||||
url: `/dashboard/${workspaceId}/artifacts`,
|
||||
icon: Shapes,
|
||||
isActive: isArtifactsActive,
|
||||
},
|
||||
isMobile
|
||||
? {
|
||||
title: "Documents",
|
||||
url: "#documents",
|
||||
icon: LibraryBig,
|
||||
isActive: isDocumentsSidebarOpen,
|
||||
}
|
||||
: null,
|
||||
{
|
||||
title: "Connectors",
|
||||
url: `/dashboard/${workspaceId}/connectors`,
|
||||
icon: Unplug,
|
||||
isActive: isConnectorsRoute,
|
||||
},
|
||||
{
|
||||
title: "Playground",
|
||||
url: `/dashboard/${workspaceId}/playground`,
|
||||
icon: SquareTerminal,
|
||||
isActive: isPlaygroundRoute,
|
||||
},
|
||||
] as (NavItem | null)[]
|
||||
).filter((item): item is NavItem => item !== null),
|
||||
[
|
||||
isMobile,
|
||||
isInboxSidebarOpen,
|
||||
isDocumentsSidebarOpen,
|
||||
totalUnreadCount,
|
||||
searchSpaceId,
|
||||
isAutomationsActive,
|
||||
isArtifactsActive,
|
||||
]
|
||||
[workspaceId, isAutomationsActive, isArtifactsActive, isConnectorsRoute, isPlaygroundRoute]
|
||||
);
|
||||
|
||||
// Handlers
|
||||
const handleSearchSpaceSelect = useCallback(
|
||||
const handleWorkspaceSelect = useCallback(
|
||||
(id: number) => {
|
||||
router.push(`/dashboard/${id}/new-chat`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleAddSearchSpace = useCallback(() => {
|
||||
setIsCreateSearchSpaceDialogOpen(true);
|
||||
}, []);
|
||||
const handleAddWorkspace = useCallback(() => {
|
||||
if (isAtWorkspaceLimit) {
|
||||
toast.error(
|
||||
`Workspace limit reached. You can own at most ${maxWorkspacesPerUser} workspaces.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
setIsCreateWorkspaceDialogOpen(true);
|
||||
}, [isAtWorkspaceLimit, maxWorkspacesPerUser]);
|
||||
|
||||
const setAnnouncementsDialog = useSetAtom(announcementsDialogAtom);
|
||||
|
||||
const handleUserSettings = useCallback(() => {
|
||||
router.push(`/dashboard/${searchSpaceId}/user-settings`);
|
||||
}, [router, searchSpaceId]);
|
||||
router.push(`/dashboard/${workspaceId}/user-settings/profile`);
|
||||
}, [router, workspaceId]);
|
||||
|
||||
const handleAnnouncements = useCallback(() => {
|
||||
setAnnouncementsDialog(true);
|
||||
}, [setAnnouncementsDialog]);
|
||||
|
||||
const handleSearchSpaceSettings = useCallback(
|
||||
(space: SearchSpace) => {
|
||||
router.push(`/dashboard/${space.id}/search-space-settings`);
|
||||
const handleWorkspaceSettings = useCallback(
|
||||
(space: Workspace) => {
|
||||
router.push(`/dashboard/${space.id}/workspace-settings`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleSearchSpaceDeleteClick = useCallback((space: SearchSpace) => {
|
||||
const handleWorkspaceDeleteClick = useCallback((space: Workspace) => {
|
||||
// If user is owner, show delete dialog; otherwise show leave dialog
|
||||
if (space.isOwner) {
|
||||
setSearchSpaceToDelete(space);
|
||||
setShowDeleteSearchSpaceDialog(true);
|
||||
setWorkspaceToDelete(space);
|
||||
setShowDeleteWorkspaceDialog(true);
|
||||
} else {
|
||||
setSearchSpaceToLeave(space);
|
||||
setShowLeaveSearchSpaceDialog(true);
|
||||
setWorkspaceToLeave(space);
|
||||
setShowLeaveWorkspaceDialog(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const confirmDeleteSearchSpace = useCallback(async () => {
|
||||
if (!searchSpaceToDelete) return;
|
||||
setIsDeletingSearchSpace(true);
|
||||
const confirmDeleteWorkspace = useCallback(async () => {
|
||||
if (!workspaceToDelete) return;
|
||||
setIsDeletingWorkspace(true);
|
||||
try {
|
||||
await deleteSearchSpace({ id: searchSpaceToDelete.id });
|
||||
await deleteWorkspace({ id: workspaceToDelete.id });
|
||||
|
||||
const isCurrentSpace = Number(searchSpaceId) === searchSpaceToDelete.id;
|
||||
const isCurrentSpace = Number(workspaceId) === workspaceToDelete.id;
|
||||
|
||||
// Await refetch so we have the freshest list (backend now hides [DELETING] spaces)
|
||||
const result = await refetchSearchSpaces();
|
||||
const updatedSpaces = (result.data ?? []).filter((s) => s.id !== searchSpaceToDelete.id);
|
||||
const result = await refetchWorkspaces();
|
||||
const updatedSpaces = (result.data ?? []).filter((s) => s.id !== workspaceToDelete.id);
|
||||
|
||||
if (isCurrentSpace) {
|
||||
if (updatedSpaces.length > 0) {
|
||||
|
|
@ -433,27 +400,27 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting search space:", error);
|
||||
console.error("Error deleting workspace:", error);
|
||||
toast.error(
|
||||
t.has("delete_space_error") ? t("delete_space_error") : "Failed to delete search space"
|
||||
t.has("delete_space_error") ? t("delete_space_error") : "Failed to delete workspace"
|
||||
);
|
||||
} finally {
|
||||
setIsDeletingSearchSpace(false);
|
||||
setShowDeleteSearchSpaceDialog(false);
|
||||
setSearchSpaceToDelete(null);
|
||||
setIsDeletingWorkspace(false);
|
||||
setShowDeleteWorkspaceDialog(false);
|
||||
setWorkspaceToDelete(null);
|
||||
}
|
||||
}, [searchSpaceToDelete, deleteSearchSpace, refetchSearchSpaces, searchSpaceId, router, t]);
|
||||
}, [workspaceToDelete, deleteWorkspace, refetchWorkspaces, workspaceId, router, t]);
|
||||
|
||||
const confirmLeaveSearchSpace = useCallback(async () => {
|
||||
if (!searchSpaceToLeave) return;
|
||||
setIsLeavingSearchSpace(true);
|
||||
const confirmLeaveWorkspace = useCallback(async () => {
|
||||
if (!workspaceToLeave) return;
|
||||
setIsLeavingWorkspace(true);
|
||||
try {
|
||||
await searchSpacesApiService.leaveSearchSpace(searchSpaceToLeave.id);
|
||||
await workspacesApiService.leaveWorkspace(workspaceToLeave.id);
|
||||
|
||||
const isCurrentSpace = Number(searchSpaceId) === searchSpaceToLeave.id;
|
||||
const isCurrentSpace = Number(workspaceId) === workspaceToLeave.id;
|
||||
|
||||
const result = await refetchSearchSpaces();
|
||||
const updatedSpaces = (result.data ?? []).filter((s) => s.id !== searchSpaceToLeave.id);
|
||||
const result = await refetchWorkspaces();
|
||||
const updatedSpaces = (result.data ?? []).filter((s) => s.id !== workspaceToLeave.id);
|
||||
|
||||
if (isCurrentSpace) {
|
||||
if (updatedSpaces.length > 0) {
|
||||
|
|
@ -463,36 +430,34 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error leaving search space:", error);
|
||||
toast.error(t.has("leave_error") ? t("leave_error") : "Failed to leave search space");
|
||||
console.error("Error leaving workspace:", error);
|
||||
toast.error(t.has("leave_error") ? t("leave_error") : "Failed to leave workspace");
|
||||
} finally {
|
||||
setIsLeavingSearchSpace(false);
|
||||
setShowLeaveSearchSpaceDialog(false);
|
||||
setSearchSpaceToLeave(null);
|
||||
setIsLeavingWorkspace(false);
|
||||
setShowLeaveWorkspaceDialog(false);
|
||||
setWorkspaceToLeave(null);
|
||||
}
|
||||
}, [searchSpaceToLeave, refetchSearchSpaces, searchSpaceId, router, t]);
|
||||
}, [workspaceToLeave, refetchWorkspaces, workspaceId, router, t]);
|
||||
|
||||
const handleTabSwitch = useCallback(
|
||||
(tab: Tab) => {
|
||||
(tab: ResolvedTab) => {
|
||||
if (tab.type === "chat") {
|
||||
activateChatThread({
|
||||
id: tab.chatId ?? null,
|
||||
title: tab.title,
|
||||
id: tab.entityId,
|
||||
url: tab.chatUrl,
|
||||
searchSpaceId: tab.searchSpaceId ?? searchSpaceId,
|
||||
workspaceId: tab.workspaceId,
|
||||
...(tab.visibility !== undefined ? { visibility: tab.visibility } : {}),
|
||||
...(tab.hasComments !== undefined ? { hasComments: tab.hasComments } : {}),
|
||||
});
|
||||
}
|
||||
// Document tabs are handled in-place by LayoutShell — no navigation needed
|
||||
},
|
||||
[activateChatThread, searchSpaceId]
|
||||
[activateChatThread]
|
||||
);
|
||||
|
||||
const handleTabPrefetch = useCallback(
|
||||
(tab: Tab) => {
|
||||
(tab: ResolvedTab) => {
|
||||
if (tab.type === "chat") {
|
||||
prefetchChatThread(tab.chatId);
|
||||
prefetchChatThread(tab.entityId);
|
||||
}
|
||||
},
|
||||
[prefetchChatThread]
|
||||
|
|
@ -507,32 +472,9 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
|
||||
const handleNavItemClick = useCallback(
|
||||
(item: NavItem) => {
|
||||
if (item.url === "#inbox") {
|
||||
setActiveSlideoutPanel((prev) => (prev === "inbox" ? null : "inbox"));
|
||||
return;
|
||||
}
|
||||
if (item.url === "#documents") {
|
||||
if (!isMobile) {
|
||||
if (!isDocumentsSidebarOpen) {
|
||||
setIsDocumentsSidebarOpen(true);
|
||||
setIsRightPanelCollapsed(false);
|
||||
setActiveSlideoutPanel(null);
|
||||
} else {
|
||||
setIsRightPanelCollapsed((prev) => !prev);
|
||||
}
|
||||
} else {
|
||||
setIsDocumentsSidebarOpen((prev) => {
|
||||
if (!prev) {
|
||||
setActiveSlideoutPanel(null);
|
||||
}
|
||||
return !prev;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
router.push(item.url);
|
||||
},
|
||||
[router, isMobile, isDocumentsSidebarOpen, setIsDocumentsSidebarOpen, setIsRightPanelCollapsed]
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleNewChat = useCallback(() => {
|
||||
|
|
@ -542,15 +484,15 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
if (isOutOfSync) {
|
||||
resetCurrentThread();
|
||||
// Immediately set the browser URL so the page remounts with a clean /new-chat path
|
||||
window.history.replaceState(null, "", `/dashboard/${searchSpaceId}/new-chat`);
|
||||
window.history.replaceState(null, "", `/dashboard/${workspaceId}/new-chat`);
|
||||
// Force-remount the page component to reset all React state synchronously
|
||||
setChatResetKey((k) => k + 1);
|
||||
// Sync Next.js router internals so useParams/usePathname stay correct going forward
|
||||
router.replace(`/dashboard/${searchSpaceId}/new-chat`);
|
||||
router.replace(`/dashboard/${workspaceId}/new-chat`);
|
||||
} else {
|
||||
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
||||
router.push(`/dashboard/${workspaceId}/new-chat`);
|
||||
}
|
||||
}, [router, searchSpaceId, currentThreadState.id, params?.chat_id, resetCurrentThread]);
|
||||
}, [router, workspaceId, currentThreadState.id, params?.chat_id, resetCurrentThread]);
|
||||
|
||||
const handleChatSelect = useCallback(
|
||||
(chat: ChatItem) => {
|
||||
|
|
@ -558,11 +500,11 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
id: chat.id,
|
||||
title: chat.name,
|
||||
url: chat.url,
|
||||
searchSpaceId,
|
||||
workspaceId,
|
||||
...(chat.visibility !== undefined ? { visibility: chat.visibility } : {}),
|
||||
});
|
||||
},
|
||||
[activateChatThread, searchSpaceId]
|
||||
[activateChatThread, workspaceId]
|
||||
);
|
||||
|
||||
const handleChatDelete = useCallback((chat: ChatItem) => {
|
||||
|
|
@ -595,12 +537,12 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
);
|
||||
|
||||
const handleSettings = useCallback(() => {
|
||||
router.push(`/dashboard/${searchSpaceId}/search-space-settings`);
|
||||
}, [router, searchSpaceId]);
|
||||
router.push(`/dashboard/${workspaceId}/workspace-settings`);
|
||||
}, [router, workspaceId]);
|
||||
|
||||
const handleManageMembers = useCallback(() => {
|
||||
router.push(`/dashboard/${searchSpaceId}/team`);
|
||||
}, [router, searchSpaceId]);
|
||||
router.push(`/dashboard/${workspaceId}/team`);
|
||||
}, [router, workspaceId]);
|
||||
|
||||
const handleLogout = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -620,10 +562,6 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
}
|
||||
}, [router]);
|
||||
|
||||
const handleViewAllChats = useCallback(() => {
|
||||
setActiveSlideoutPanel((prev) => (prev === "chats" ? null : "chats"));
|
||||
}, []);
|
||||
|
||||
// Delete handlers
|
||||
const confirmDeleteChat = useCallback(async () => {
|
||||
if (!chatToDelete) return;
|
||||
|
|
@ -633,24 +571,20 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
const fallbackTab = removeChatTab(chatToDelete.id);
|
||||
if (currentChatId === chatToDelete.id) {
|
||||
resetCurrentThread();
|
||||
if (fallbackTab?.type === "chat" && fallbackTab.chatUrl) {
|
||||
if (fallbackTab?.type === "chat") {
|
||||
const fallbackWorkspaceId = fallbackTab.workspaceId || Number(workspaceId);
|
||||
activateChatThread({
|
||||
id: fallbackTab.chatId ?? null,
|
||||
title: fallbackTab.title,
|
||||
url: fallbackTab.chatUrl,
|
||||
searchSpaceId: fallbackTab.searchSpaceId ?? searchSpaceId,
|
||||
...(fallbackTab.visibility !== undefined ? { visibility: fallbackTab.visibility } : {}),
|
||||
...(fallbackTab.hasComments !== undefined
|
||||
? { hasComments: fallbackTab.hasComments }
|
||||
: {}),
|
||||
id: fallbackTab.entityId,
|
||||
url: getChatUrl(fallbackWorkspaceId, fallbackTab.entityId),
|
||||
workspaceId: fallbackWorkspaceId,
|
||||
});
|
||||
} else {
|
||||
const isOutOfSync = currentThreadState.id !== null && !params?.chat_id;
|
||||
if (isOutOfSync) {
|
||||
window.history.replaceState(null, "", `/dashboard/${searchSpaceId}/new-chat`);
|
||||
window.history.replaceState(null, "", `/dashboard/${workspaceId}/new-chat`);
|
||||
setChatResetKey((k) => k + 1);
|
||||
} else {
|
||||
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
||||
router.push(`/dashboard/${workspaceId}/new-chat`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -664,7 +598,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
}, [
|
||||
chatToDelete,
|
||||
deleteThread,
|
||||
searchSpaceId,
|
||||
workspaceId,
|
||||
resetCurrentThread,
|
||||
currentChatId,
|
||||
currentThreadState.id,
|
||||
|
|
@ -699,28 +633,49 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
// Detect if we're on the chat page (needs overflow-hidden for chat's own scroll)
|
||||
const isChatPage = pathname?.includes("/new-chat") ?? false;
|
||||
const isUserSettingsPage = pathname?.includes("/user-settings") === true;
|
||||
const isSearchSpaceSettingsPage = pathname?.includes("/search-space-settings") === true;
|
||||
const isWorkspaceSettingsPage = pathname?.includes("/workspace-settings") === true;
|
||||
const isTeamPage = pathname?.endsWith("/team") === true;
|
||||
const isAutomationsPage = pathname?.includes("/automations") === true;
|
||||
const isArtifactsPage = pathname?.endsWith("/artifacts") === true;
|
||||
const isPlaygroundPage = pathname?.includes("/playground") === true;
|
||||
const isConnectorsPage = pathname?.includes("/connectors") === true;
|
||||
const isAllChatsPage = pathname?.endsWith("/chats") === true;
|
||||
const handleChatsClick = useCallback(() => {
|
||||
router.push(`/dashboard/${workspaceId}/chats`);
|
||||
}, [router, workspaceId]);
|
||||
const handleViewAllChats = useCallback(() => {
|
||||
router.push(
|
||||
isAllChatsPage ? `/dashboard/${workspaceId}/new-chat` : `/dashboard/${workspaceId}/chats`
|
||||
);
|
||||
}, [isAllChatsPage, router, workspaceId]);
|
||||
const useWorkspacePanel =
|
||||
pathname?.endsWith("/buy-more") === true ||
|
||||
pathname?.endsWith("/earn-credits") === true ||
|
||||
pathname?.endsWith("/more-pages") === true ||
|
||||
isUserSettingsPage ||
|
||||
isSearchSpaceSettingsPage ||
|
||||
isWorkspaceSettingsPage ||
|
||||
isTeamPage ||
|
||||
isAutomationsPage;
|
||||
isAutomationsPage ||
|
||||
isArtifactsPage ||
|
||||
isPlaygroundPage ||
|
||||
isConnectorsPage ||
|
||||
isAllChatsPage;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Persistent host: keeps an in-flight chat turn streaming across
|
||||
in-app navigation and aborts it only on workspace teardown. */}
|
||||
<ActiveChatStreamRunner />
|
||||
<LayoutShell
|
||||
searchSpaces={searchSpaces}
|
||||
activeSearchSpaceId={Number(searchSpaceId)}
|
||||
onSearchSpaceSelect={handleSearchSpaceSelect}
|
||||
onSearchSpaceDelete={handleSearchSpaceDeleteClick}
|
||||
onSearchSpaceSettings={handleSearchSpaceSettings}
|
||||
onAddSearchSpace={handleAddSearchSpace}
|
||||
searchSpace={activeSearchSpace}
|
||||
workspaces={workspaces}
|
||||
activeWorkspaceId={Number(workspaceId)}
|
||||
onWorkspaceSelect={handleWorkspaceSelect}
|
||||
onWorkspaceDelete={handleWorkspaceDeleteClick}
|
||||
onWorkspaceSettings={handleWorkspaceSettings}
|
||||
onAddWorkspace={handleAddWorkspace}
|
||||
isAtWorkspaceLimit={isAtWorkspaceLimit}
|
||||
maxWorkspacesPerUser={maxWorkspacesPerUser}
|
||||
workspace={activeWorkspace}
|
||||
navItems={navItems}
|
||||
onNavItemClick={handleNavItemClick}
|
||||
chats={chats}
|
||||
|
|
@ -731,6 +686,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
onChatRename={handleChatRename}
|
||||
onChatDelete={handleChatDelete}
|
||||
onChatArchive={handleChatArchive}
|
||||
onChatsClick={handleChatsClick}
|
||||
onViewAllChats={handleViewAllChats}
|
||||
user={{
|
||||
email: user?.email || "",
|
||||
|
|
@ -746,28 +702,28 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
theme={theme}
|
||||
setTheme={setTheme}
|
||||
isChatPage={isChatPage}
|
||||
isAllChatsPage={isAllChatsPage}
|
||||
useWorkspacePanel={useWorkspacePanel}
|
||||
workspacePanelViewportClassName={
|
||||
isUserSettingsPage || isSearchSpaceSettingsPage || isTeamPage || isAutomationsPage
|
||||
? "items-start justify-center px-6 py-8 md:px-10 md:py-10"
|
||||
isUserSettingsPage ||
|
||||
isWorkspaceSettingsPage ||
|
||||
isTeamPage ||
|
||||
isAutomationsPage ||
|
||||
isArtifactsPage ||
|
||||
isPlaygroundPage ||
|
||||
isConnectorsPage ||
|
||||
isAllChatsPage
|
||||
? "items-start justify-center px-6 py-8 md:px-10 md:pb-10 md:pt-16"
|
||||
: undefined
|
||||
}
|
||||
workspacePanelContentClassName={
|
||||
isAutomationsPage
|
||||
? "max-w-none select-none"
|
||||
: isUserSettingsPage || isSearchSpaceSettingsPage || isTeamPage
|
||||
? "max-w-5xl"
|
||||
: undefined
|
||||
}
|
||||
workspacePanelContentClassName={useWorkspacePanel ? "max-w-5xl select-none" : undefined}
|
||||
isLoadingChats={isLoadingThreads}
|
||||
activeSlideoutPanel={activeSlideoutPanel}
|
||||
onSlideoutPanelChange={setActiveSlideoutPanel}
|
||||
inbox={{
|
||||
isOpen: isInboxSidebarOpen,
|
||||
notifications={{
|
||||
totalUnreadCount,
|
||||
comments: {
|
||||
items: commentsInbox.inboxItems,
|
||||
unreadCount: commentsInbox.unreadCount,
|
||||
totalCount: commentsInbox.totalCount,
|
||||
loading: commentsInbox.loading,
|
||||
loadingMore: commentsInbox.loadingMore,
|
||||
hasMore: commentsInbox.hasMore,
|
||||
|
|
@ -778,6 +734,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
status: {
|
||||
items: statusInbox.inboxItems,
|
||||
unreadCount: statusInbox.unreadCount,
|
||||
totalCount: statusInbox.totalCount,
|
||||
loading: statusInbox.loading,
|
||||
loadingMore: statusInbox.loadingMore,
|
||||
hasMore: statusInbox.hasMore,
|
||||
|
|
@ -786,15 +743,10 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
markAllAsRead: statusInbox.markAllAsRead,
|
||||
},
|
||||
}}
|
||||
allChatsPanel={{
|
||||
searchSpaceId,
|
||||
}}
|
||||
documentsPanel={{
|
||||
open: isDocumentsSidebarOpen,
|
||||
onOpenChange: setIsDocumentsSidebarOpen,
|
||||
}}
|
||||
onTabSwitch={handleTabSwitch}
|
||||
onTabPrefetch={handleTabPrefetch}
|
||||
playgroundSidebar={<PlaygroundSidebar workspaceId={workspaceId} />}
|
||||
initialPlaygroundSidebarCollapsed={initialPlaygroundSidebarCollapsed}
|
||||
>
|
||||
<Fragment key={chatResetKey}>{children}</Fragment>
|
||||
</LayoutShell>
|
||||
|
|
@ -877,66 +829,64 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Search Space Dialog */}
|
||||
<AlertDialog open={showDeleteSearchSpaceDialog} onOpenChange={setShowDeleteSearchSpaceDialog}>
|
||||
{/* Delete Workspace Dialog */}
|
||||
<AlertDialog open={showDeleteWorkspaceDialog} onOpenChange={setShowDeleteWorkspaceDialog}>
|
||||
<AlertDialogContent className="sm:max-w-md">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("delete_search_space")}</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t("delete_workspace")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("delete_space_confirm", { name: searchSpaceToDelete?.name || "" })}
|
||||
{t("delete_space_confirm", { name: workspaceToDelete?.name || "" })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeletingSearchSpace}>
|
||||
<AlertDialogCancel disabled={isDeletingWorkspace}>
|
||||
{tCommon("cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
confirmDeleteSearchSpace();
|
||||
confirmDeleteWorkspace();
|
||||
}}
|
||||
disabled={isDeletingSearchSpace}
|
||||
disabled={isDeletingWorkspace}
|
||||
className="relative bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
<span className={isDeletingSearchSpace ? "opacity-0" : ""}>{tCommon("delete")}</span>
|
||||
{isDeletingSearchSpace && <Spinner size="sm" className="absolute" />}
|
||||
<span className={isDeletingWorkspace ? "opacity-0" : ""}>{tCommon("delete")}</span>
|
||||
{isDeletingWorkspace && <Spinner size="sm" className="absolute" />}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Leave Search Space Dialog */}
|
||||
<AlertDialog open={showLeaveSearchSpaceDialog} onOpenChange={setShowLeaveSearchSpaceDialog}>
|
||||
{/* Leave Workspace Dialog */}
|
||||
<AlertDialog open={showLeaveWorkspaceDialog} onOpenChange={setShowLeaveWorkspaceDialog}>
|
||||
<AlertDialogContent className="sm:max-w-md">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("leave_title")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("leave_confirm", { name: searchSpaceToLeave?.name || "" })}
|
||||
{t("leave_confirm", { name: workspaceToLeave?.name || "" })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isLeavingSearchSpace}>
|
||||
{tCommon("cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogCancel disabled={isLeavingWorkspace}>{tCommon("cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
confirmLeaveSearchSpace();
|
||||
confirmLeaveWorkspace();
|
||||
}}
|
||||
disabled={isLeavingSearchSpace}
|
||||
disabled={isLeavingWorkspace}
|
||||
className="relative bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
<span className={isLeavingSearchSpace ? "opacity-0" : ""}>{t("leave")}</span>
|
||||
{isLeavingSearchSpace && <Spinner size="sm" className="absolute" />}
|
||||
<span className={isLeavingWorkspace ? "opacity-0" : ""}>{t("leave")}</span>
|
||||
{isLeavingWorkspace && <Spinner size="sm" className="absolute" />}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Create Search Space Dialog */}
|
||||
<CreateSearchSpaceDialog
|
||||
open={isCreateSearchSpaceDialogOpen}
|
||||
onOpenChange={setIsCreateSearchSpaceDialogOpen}
|
||||
{/* Create Workspace Dialog */}
|
||||
<CreateWorkspaceDialog
|
||||
open={isCreateWorkspaceDialogOpen}
|
||||
onOpenChange={setIsCreateWorkspaceDialogOpen}
|
||||
/>
|
||||
|
||||
<AnnouncementsDialog />
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { LucideIcon } from "lucide-react";
|
||||
import type { DocumentsProcessingStatus } from "@/hooks/use-documents-processing";
|
||||
|
||||
export interface SearchSpace {
|
||||
export interface Workspace {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
|
|
@ -41,15 +41,15 @@ export interface PageUsage {
|
|||
}
|
||||
|
||||
export interface IconRailProps {
|
||||
searchSpaces: SearchSpace[];
|
||||
activeSearchSpaceId: number | null;
|
||||
onSearchSpaceSelect: (id: number) => void;
|
||||
onAddSearchSpace: () => void;
|
||||
workspaces: Workspace[];
|
||||
activeWorkspaceId: number | null;
|
||||
onWorkspaceSelect: (id: number) => void;
|
||||
onAddWorkspace: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface SidebarHeaderProps {
|
||||
searchSpace: SearchSpace | null;
|
||||
workspace: Workspace | null;
|
||||
onSettings?: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -71,23 +71,23 @@ export interface ChatsSectionProps {
|
|||
onChatSelect: (chat: ChatItem) => void;
|
||||
onChatDelete?: (chat: ChatItem) => void;
|
||||
onViewAllChats?: () => void;
|
||||
searchSpaceId?: string;
|
||||
workspaceId?: string;
|
||||
}
|
||||
|
||||
export interface SidebarUserProfileProps {
|
||||
user: User;
|
||||
searchSpaceId?: string;
|
||||
workspaceId?: string;
|
||||
onSettings?: () => void;
|
||||
onManageMembers?: () => void;
|
||||
onSwitchSearchSpace?: () => void;
|
||||
onSwitchWorkspace?: () => void;
|
||||
onToggleTheme?: () => void;
|
||||
onLogout?: () => void;
|
||||
theme?: string;
|
||||
}
|
||||
|
||||
export interface SidebarProps {
|
||||
searchSpace: SearchSpace | null;
|
||||
searchSpaceId?: string;
|
||||
workspace: Workspace | null;
|
||||
workspaceId?: string;
|
||||
navItems: NavItem[];
|
||||
chats: ChatItem[];
|
||||
activeChatId?: number | null;
|
||||
|
|
@ -106,10 +106,10 @@ export interface SidebarProps {
|
|||
}
|
||||
|
||||
export interface LayoutShellProps {
|
||||
searchSpaces: SearchSpace[];
|
||||
activeSearchSpaceId: number | null;
|
||||
onSearchSpaceSelect: (id: number) => void;
|
||||
onAddSearchSpace: () => void;
|
||||
workspaces: Workspace[];
|
||||
activeWorkspaceId: number | null;
|
||||
onWorkspaceSelect: (id: number) => void;
|
||||
onAddWorkspace: () => void;
|
||||
sidebarProps: Omit<SidebarProps, "className">;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
|
|
|
|||
278
surfsense_web/components/layout/ui/RoutedSectionShell.tsx
Normal file
278
surfsense_web/components/layout/ui/RoutedSectionShell.tsx
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerHandle,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/components/ui/drawer";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface RoutedSectionItem {
|
||||
value: string;
|
||||
label: string;
|
||||
href: string;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface RoutedSectionGroup {
|
||||
value: string;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
items: RoutedSectionItem[];
|
||||
}
|
||||
|
||||
interface RoutedSectionShellProps {
|
||||
title: string;
|
||||
items: RoutedSectionItem[];
|
||||
activeValue: string;
|
||||
selectedLabel: string;
|
||||
children: React.ReactNode;
|
||||
groups?: RoutedSectionGroup[];
|
||||
mobileNav?: "scroll" | "drawer";
|
||||
contentClassName?: string;
|
||||
desktopNav?: boolean;
|
||||
}
|
||||
|
||||
function findActiveGroupValue(groups: RoutedSectionGroup[], activeValue: string): string | null {
|
||||
return (
|
||||
groups.find((group) => group.items.some((item) => item.value === activeValue))?.value ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function SectionNavLink({
|
||||
item,
|
||||
activeValue,
|
||||
onNavigate,
|
||||
}: {
|
||||
item: RoutedSectionItem;
|
||||
activeValue: string;
|
||||
onNavigate?: () => void;
|
||||
}) {
|
||||
const isActive = activeValue === item.value;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={item.href}
|
||||
replace
|
||||
scroll={false}
|
||||
prefetch
|
||||
onClick={onNavigate}
|
||||
className={cn(
|
||||
"inline-flex h-auto items-center justify-start gap-3 rounded-md px-3 py-2.5 text-left text-sm font-medium transition-colors duration-150 focus:outline-none focus-visible:outline-none",
|
||||
isActive
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
<span className="min-w-0 truncate">{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionNavGroup({
|
||||
group,
|
||||
activeValue,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
onNavigate,
|
||||
}: {
|
||||
group: RoutedSectionGroup;
|
||||
activeValue: string;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
onNavigate?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={isExpanded}
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
"inline-flex h-auto items-center justify-start gap-3 rounded-md px-3 py-2.5 text-left text-sm font-medium transition-colors duration-150 focus:outline-none focus-visible:outline-none",
|
||||
isExpanded
|
||||
? "text-accent-foreground"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
{group.icon}
|
||||
<span className="min-w-0 truncate">{group.label}</span>
|
||||
<ChevronRight
|
||||
className={cn("ml-auto h-4 w-4 shrink-0 transition-transform", isExpanded && "rotate-90")}
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className={cn(
|
||||
"grid overflow-hidden transition-[grid-template-rows,opacity] duration-200 ease-out",
|
||||
isExpanded ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
|
||||
)}
|
||||
aria-hidden={!isExpanded}
|
||||
>
|
||||
<div className="min-h-0 overflow-hidden">
|
||||
<div className="flex flex-col gap-0.5 pl-10">
|
||||
{group.items.map((item) => (
|
||||
<SectionNavLink
|
||||
key={item.value}
|
||||
item={item}
|
||||
activeValue={activeValue}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RoutedSectionShell({
|
||||
title,
|
||||
items,
|
||||
activeValue,
|
||||
selectedLabel,
|
||||
children,
|
||||
groups = [],
|
||||
mobileNav = "scroll",
|
||||
contentClassName,
|
||||
desktopNav = true,
|
||||
}: RoutedSectionShellProps) {
|
||||
const [tabScrollPos, setTabScrollPos] = useState<"start" | "middle" | "end">("start");
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [expandedGroup, setExpandedGroup] = useState<string | null>(() =>
|
||||
findActiveGroupValue(groups, activeValue)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const activeGroup = findActiveGroupValue(groups, activeValue);
|
||||
if (activeGroup) {
|
||||
setExpandedGroup(activeGroup);
|
||||
}
|
||||
}, [activeValue, groups]);
|
||||
|
||||
const handleTabScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||
const el = e.currentTarget;
|
||||
const atStart = el.scrollLeft <= 2;
|
||||
const atEnd = el.scrollWidth - el.scrollLeft - el.clientWidth <= 2;
|
||||
setTabScrollPos(atStart ? "start" : atEnd ? "end" : "middle");
|
||||
}, []);
|
||||
|
||||
const renderNav = (onNavigate?: () => void) => (
|
||||
<>
|
||||
{items.map((item) => (
|
||||
<SectionNavLink
|
||||
key={item.value}
|
||||
item={item}
|
||||
activeValue={activeValue}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))}
|
||||
{groups.length > 0 && (
|
||||
<>
|
||||
<Separator className="my-3 bg-border" />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{groups.map((group) => (
|
||||
<SectionNavGroup
|
||||
key={group.value}
|
||||
group={group}
|
||||
activeValue={activeValue}
|
||||
isExpanded={expandedGroup === group.value}
|
||||
onToggle={() =>
|
||||
setExpandedGroup((current) => (current === group.value ? null : group.value))
|
||||
}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
"flex h-full min-h-[min(680px,calc(100vh-5rem))] w-full select-none flex-col",
|
||||
desktopNav ? "gap-6 md:flex-row" : "gap-4 md:block"
|
||||
)}
|
||||
>
|
||||
<div className={cn("md:w-[220px] md:shrink-0", !desktopNav && "md:hidden")}>
|
||||
<h1 className="mb-4 px-1 text-xl font-semibold tracking-tight text-foreground md:text-2xl">
|
||||
{title}
|
||||
</h1>
|
||||
{desktopNav ? <nav className="hidden flex-col gap-0.5 md:flex">{renderNav()}</nav> : null}
|
||||
{mobileNav === "drawer" ? (
|
||||
<Drawer open={drawerOpen} onOpenChange={setDrawerOpen} shouldScaleBackground={false}>
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="flex h-10 w-full justify-between bg-transparent px-3 hover:bg-accent md:hidden"
|
||||
>
|
||||
<span className="truncate">{selectedLabel}</span>
|
||||
<ChevronRight className="h-4 w-4 rotate-90 text-muted-foreground" />
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent className="h-[88vh] overflow-hidden rounded-t-2xl border bg-popover text-popover-foreground">
|
||||
<DrawerHandle className="mt-3 h-1.5 w-10" />
|
||||
<DrawerTitle className="sr-only">{title} navigation</DrawerTitle>
|
||||
<nav className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-4">
|
||||
{renderNav(() => setDrawerOpen(false))}
|
||||
</nav>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
) : (
|
||||
<div
|
||||
className="overflow-x-auto border-b border-border pb-3 md:hidden"
|
||||
onScroll={handleTabScroll}
|
||||
style={{
|
||||
maskImage: `linear-gradient(to right, ${tabScrollPos === "start" ? "black" : "transparent"}, black 24px, black calc(100% - 24px), ${tabScrollPos === "end" ? "black" : "transparent"})`,
|
||||
WebkitMaskImage: `linear-gradient(to right, ${tabScrollPos === "start" ? "black" : "transparent"}, black 24px, black calc(100% - 24px), ${tabScrollPos === "end" ? "black" : "transparent"})`,
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-1">
|
||||
{items.map((item) => (
|
||||
<Link
|
||||
key={item.value}
|
||||
href={item.href}
|
||||
replace
|
||||
scroll={false}
|
||||
prefetch
|
||||
className={cn(
|
||||
"inline-flex h-auto shrink-0 items-center gap-2 rounded-md px-3 py-1.5 text-xs font-medium transition-colors duration-150 focus:outline-none focus-visible:outline-none",
|
||||
activeValue === item.value
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
{desktopNav ? (
|
||||
<div className="hidden md:block">
|
||||
<h2 className="text-lg font-semibold">{selectedLabel}</h2>
|
||||
<Separator className="mt-4 bg-border" />
|
||||
</div>
|
||||
) : null}
|
||||
<div className={cn("min-w-0", desktopNav ? "pt-4" : "pt-4 md:pt-0", contentClassName)}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,8 +6,9 @@ import { useRouter } from "next/navigation";
|
|||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import { createSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
|
||||
import { createWorkspaceMutationAtom } from "@/atoms/workspaces/workspace-mutation.atoms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -27,7 +28,8 @@ import {
|
|||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { trackSearchSpaceCreated } from "@/lib/posthog/events";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { queryClient } from "@/lib/query-client/client";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
|
|
@ -36,18 +38,18 @@ const formSchema = z.object({
|
|||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
interface CreateSearchSpaceDialogProps {
|
||||
interface CreateWorkspaceDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function CreateSearchSpaceDialog({ open, onOpenChange }: CreateSearchSpaceDialogProps) {
|
||||
const t = useTranslations("searchSpace");
|
||||
export function CreateWorkspaceDialog({ open, onOpenChange }: CreateWorkspaceDialogProps) {
|
||||
const t = useTranslations("workspace");
|
||||
const tCommon = useTranslations("common");
|
||||
const router = useRouter();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const { mutateAsync: createSearchSpace } = useAtomValue(createSearchSpaceMutationAtom);
|
||||
const { mutateAsync: createWorkspace } = useAtomValue(createWorkspaceMutationAtom);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
|
|
@ -60,16 +62,31 @@ export function CreateSearchSpaceDialog({ open, onOpenChange }: CreateSearchSpac
|
|||
const handleSubmit = async (values: FormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const result = await createSearchSpace({
|
||||
const result = await createWorkspace({
|
||||
name: values.name,
|
||||
description: values.description || "",
|
||||
});
|
||||
|
||||
trackSearchSpaceCreated(result.id, values.name);
|
||||
// workspace_created is now emitted server-side (workspaces_routes.py)
|
||||
// so PAT/MCP-created workspaces are also counted.
|
||||
|
||||
router.push(`/dashboard/${result.id}/new-chat`);
|
||||
// Seed the gate's query so it resolves without a loader flash, and
|
||||
// route straight to onboarding vs. new-chat on the first hop.
|
||||
if (result.llm_setup) {
|
||||
queryClient.setQueryData(
|
||||
cacheKeys.modelConnections.setupStatus(result.id),
|
||||
result.llm_setup
|
||||
);
|
||||
}
|
||||
// A fresh workspace can never be recovery, so this matches the gate,
|
||||
// which is the authoritative net regardless.
|
||||
const isInitialSetup = result.llm_setup?.stage === "initial_setup";
|
||||
router.push(
|
||||
isInitialSetup ? `/dashboard/${result.id}/onboard` : `/dashboard/${result.id}/new-chat`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to create search space:", error);
|
||||
console.error("Failed to create workspace:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to create workspace");
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
|
@ -1 +1 @@
|
|||
export { CreateSearchSpaceDialog } from "./CreateSearchSpaceDialog";
|
||||
export { CreateWorkspaceDialog } from "./CreateWorkspaceDialog";
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
import { useAtomValue } from "jotai";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
|
||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { activeTabAtom } from "@/atoms/tabs/tabs.atom";
|
||||
import { activeWorkspaceIdAtom } from "@/atoms/workspaces/workspace-query.atoms";
|
||||
import { ActionLogButton } from "@/components/agent-action-log/action-log-button";
|
||||
import { ChatShareButton } from "@/components/new-chat/chat-share-button";
|
||||
import { ArtifactsToggleButton } from "@/features/chat-artifacts";
|
||||
|
|
@ -16,7 +16,7 @@ interface HeaderProps {
|
|||
|
||||
export function Header({ mobileMenuTrigger }: HeaderProps) {
|
||||
const pathname = usePathname();
|
||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||
const workspaceId = useAtomValue(activeWorkspaceIdAtom);
|
||||
const activeTab = useAtomValue(activeTabAtom);
|
||||
|
||||
const isFreePage = pathname?.startsWith("/free") ?? false;
|
||||
|
|
@ -26,14 +26,14 @@ export function Header({ mobileMenuTrigger }: HeaderProps) {
|
|||
const currentThreadState = useAtomValue(currentThreadAtom);
|
||||
|
||||
const hasThread = isChatPage && !isDocumentTab && currentThreadState.id !== null;
|
||||
const activeSearchSpaceId = searchSpaceId ? Number(searchSpaceId) : null;
|
||||
const activeWorkspaceId = workspaceId ? Number(workspaceId) : null;
|
||||
const canRenderShareButton =
|
||||
hasThread &&
|
||||
currentThreadState.id !== null &&
|
||||
currentThreadState.visibility !== null &&
|
||||
currentThreadState.searchSpaceId !== null &&
|
||||
activeSearchSpaceId !== null &&
|
||||
currentThreadState.searchSpaceId === activeSearchSpaceId;
|
||||
currentThreadState.workspaceId !== null &&
|
||||
activeWorkspaceId !== null &&
|
||||
currentThreadState.workspaceId === activeWorkspaceId;
|
||||
|
||||
// Free chat pages have their own header with model selector; only render mobile trigger
|
||||
if (isFreePage) {
|
||||
|
|
@ -50,13 +50,13 @@ export function Header({ mobileMenuTrigger }: HeaderProps) {
|
|||
canRenderShareButton &&
|
||||
currentThreadState.id !== null &&
|
||||
currentThreadState.visibility !== null &&
|
||||
currentThreadState.searchSpaceId !== null
|
||||
currentThreadState.workspaceId !== null
|
||||
) {
|
||||
threadForButton = {
|
||||
id: currentThreadState.id,
|
||||
visibility: currentThreadState.visibility,
|
||||
created_by_id: null,
|
||||
search_space_id: currentThreadState.searchSpaceId,
|
||||
workspace_id: currentThreadState.workspaceId,
|
||||
title: "",
|
||||
archived: false,
|
||||
created_at: "",
|
||||
|
|
|
|||
|
|
@ -5,17 +5,23 @@ import { Button } from "@/components/ui/button";
|
|||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { NavItem, SearchSpace, User } from "../../types/layout.types";
|
||||
import type { NavItem, User, Workspace } from "../../types/layout.types";
|
||||
import {
|
||||
NotificationsDropdown,
|
||||
type NotificationsDropdownData,
|
||||
} from "../sidebar/NotificationsDropdown";
|
||||
import { SidebarUserProfile } from "../sidebar/SidebarUserProfile";
|
||||
import { SearchSpaceAvatar } from "./SearchSpaceAvatar";
|
||||
import { WorkspaceAvatar } from "./WorkspaceAvatar";
|
||||
|
||||
interface IconRailProps {
|
||||
searchSpaces: SearchSpace[];
|
||||
activeSearchSpaceId: number | null;
|
||||
onSearchSpaceSelect: (id: number) => void;
|
||||
onSearchSpaceDelete?: (searchSpace: SearchSpace) => void;
|
||||
onSearchSpaceSettings?: (searchSpace: SearchSpace) => void;
|
||||
onAddSearchSpace: () => void;
|
||||
workspaces: Workspace[];
|
||||
activeWorkspaceId: number | null;
|
||||
onWorkspaceSelect: (id: number) => void;
|
||||
onWorkspaceDelete?: (workspace: Workspace) => void;
|
||||
onWorkspaceSettings?: (workspace: Workspace) => void;
|
||||
onAddWorkspace: () => void;
|
||||
isAtWorkspaceLimit?: boolean;
|
||||
maxWorkspacesPerUser?: number;
|
||||
isSingleRailMode?: boolean;
|
||||
onNewChat?: () => void;
|
||||
navItems?: NavItem[];
|
||||
|
|
@ -24,6 +30,7 @@ interface IconRailProps {
|
|||
onUserSettings?: () => void;
|
||||
onAnnouncements?: () => void;
|
||||
announcementUnreadCount?: number;
|
||||
notifications?: NotificationsDropdownData;
|
||||
onLogout?: () => void;
|
||||
theme?: string;
|
||||
setTheme?: (theme: "light" | "dark" | "system") => void;
|
||||
|
|
@ -31,12 +38,14 @@ interface IconRailProps {
|
|||
}
|
||||
|
||||
export function IconRail({
|
||||
searchSpaces,
|
||||
activeSearchSpaceId,
|
||||
onSearchSpaceSelect,
|
||||
onSearchSpaceDelete,
|
||||
onSearchSpaceSettings,
|
||||
onAddSearchSpace,
|
||||
workspaces,
|
||||
activeWorkspaceId,
|
||||
onWorkspaceSelect,
|
||||
onWorkspaceDelete,
|
||||
onWorkspaceSettings,
|
||||
onAddWorkspace,
|
||||
isAtWorkspaceLimit = false,
|
||||
maxWorkspacesPerUser,
|
||||
isSingleRailMode = false,
|
||||
onNewChat,
|
||||
navItems = [],
|
||||
|
|
@ -45,6 +54,7 @@ export function IconRail({
|
|||
onUserSettings,
|
||||
onAnnouncements,
|
||||
announcementUnreadCount = 0,
|
||||
notifications,
|
||||
onLogout,
|
||||
theme,
|
||||
setTheme,
|
||||
|
|
@ -72,41 +82,46 @@ export function IconRail({
|
|||
})),
|
||||
]
|
||||
: [];
|
||||
const addWorkspaceLabel =
|
||||
isAtWorkspaceLimit && maxWorkspacesPerUser !== undefined
|
||||
? `Workspace limit reached: ${maxWorkspacesPerUser}`
|
||||
: "Add workspace";
|
||||
|
||||
return (
|
||||
<div className={cn("flex h-full w-14 min-h-0 flex-col items-center", className)}>
|
||||
<ScrollArea className="w-full min-h-0 flex-1">
|
||||
<div className="flex flex-col items-center gap-2 px-1.5 py-3">
|
||||
{searchSpaces.map((searchSpace) => (
|
||||
<SearchSpaceAvatar
|
||||
key={searchSpace.id}
|
||||
name={searchSpace.name}
|
||||
isActive={searchSpace.id === activeSearchSpaceId}
|
||||
isShared={searchSpace.memberCount > 1}
|
||||
isOwner={searchSpace.isOwner}
|
||||
onClick={() => onSearchSpaceSelect(searchSpace.id)}
|
||||
onDelete={onSearchSpaceDelete ? () => onSearchSpaceDelete(searchSpace) : undefined}
|
||||
onSettings={
|
||||
onSearchSpaceSettings ? () => onSearchSpaceSettings(searchSpace) : undefined
|
||||
}
|
||||
{workspaces.map((workspace) => (
|
||||
<WorkspaceAvatar
|
||||
key={workspace.id}
|
||||
name={workspace.name}
|
||||
isActive={workspace.id === activeWorkspaceId}
|
||||
isShared={workspace.memberCount > 1}
|
||||
isOwner={workspace.isOwner}
|
||||
onClick={() => onWorkspaceSelect(workspace.id)}
|
||||
onDelete={onWorkspaceDelete ? () => onWorkspaceDelete(workspace) : undefined}
|
||||
onSettings={onWorkspaceSettings ? () => onWorkspaceSettings(workspace) : undefined}
|
||||
size="md"
|
||||
/>
|
||||
))}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onAddSearchSpace}
|
||||
className="h-10 w-10 rounded-lg border-2 border-dashed border-muted-foreground/30 hover:border-muted-foreground/50"
|
||||
>
|
||||
<Plus className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="sr-only">Add search space</span>
|
||||
</Button>
|
||||
<span className="inline-flex">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onAddWorkspace}
|
||||
disabled={isAtWorkspaceLimit}
|
||||
className="h-10 w-10 rounded-lg border-2 border-dashed border-muted-foreground/30 hover:border-muted-foreground/50 disabled:opacity-50"
|
||||
>
|
||||
<Plus className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="sr-only">{addWorkspaceLabel}</span>
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
Add search space
|
||||
{addWorkspaceLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
|
|
@ -147,6 +162,9 @@ export function IconRail({
|
|||
isCollapsed
|
||||
theme={theme}
|
||||
setTheme={setTheme}
|
||||
topContent={
|
||||
notifications ? <NotificationsDropdown notifications={notifications} /> : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import {
|
|||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SearchSpaceAvatarProps {
|
||||
interface WorkspaceAvatarProps {
|
||||
name: string;
|
||||
isActive?: boolean;
|
||||
isShared?: boolean;
|
||||
|
|
@ -27,7 +27,7 @@ interface SearchSpaceAvatarProps {
|
|||
}
|
||||
|
||||
/**
|
||||
* Generates a consistent color based on search space name
|
||||
* Generates a consistent color based on workspace name
|
||||
*/
|
||||
function stringToColor(str: string): string {
|
||||
let hash = 0;
|
||||
|
|
@ -48,7 +48,7 @@ function stringToColor(str: string): string {
|
|||
}
|
||||
|
||||
/**
|
||||
* Gets initials from search space name (max 2 chars)
|
||||
* Gets initials from workspace name (max 2 chars)
|
||||
*/
|
||||
function getInitials(name: string): string {
|
||||
const words = name.trim().split(/\s+/);
|
||||
|
|
@ -58,7 +58,7 @@ function getInitials(name: string): string {
|
|||
return name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
export function SearchSpaceAvatar({
|
||||
export function WorkspaceAvatar({
|
||||
name,
|
||||
isActive,
|
||||
isShared,
|
||||
|
|
@ -68,8 +68,8 @@ export function SearchSpaceAvatar({
|
|||
onSettings,
|
||||
size = "md",
|
||||
disableTooltip = false,
|
||||
}: SearchSpaceAvatarProps) {
|
||||
const t = useTranslations("searchSpace");
|
||||
}: WorkspaceAvatarProps) {
|
||||
const t = useTranslations("workspace");
|
||||
const tCommon = useTranslations("common");
|
||||
const bgColor = stringToColor(name);
|
||||
const initials = getInitials(name);
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
export { IconRail } from "./IconRail";
|
||||
export { NavIcon } from "./NavIcon";
|
||||
export { SearchSpaceAvatar } from "./SearchSpaceAvatar";
|
||||
export { WorkspaceAvatar } from "./WorkspaceAvatar";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,15 @@
|
|||
export { CreateSearchSpaceDialog } from "./dialogs";
|
||||
export { CreateWorkspaceDialog } from "./dialogs";
|
||||
export { Header } from "./header";
|
||||
export { IconRail, NavIcon, SearchSpaceAvatar } from "./icon-rail";
|
||||
export { IconRail, NavIcon, WorkspaceAvatar } from "./icon-rail";
|
||||
export {
|
||||
getPlaygroundActiveValue,
|
||||
getPlaygroundNavGroups,
|
||||
getPlaygroundNavItems,
|
||||
getPlaygroundSelectedLabel,
|
||||
PlaygroundSidebar,
|
||||
} from "./playground/PlaygroundSidebar";
|
||||
export type { RoutedSectionGroup, RoutedSectionItem } from "./RoutedSectionShell";
|
||||
export { RoutedSectionShell } from "./RoutedSectionShell";
|
||||
export { LayoutShell } from "./shell";
|
||||
export {
|
||||
ChatListItem,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,221 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronRight, History, KeyRound, LayoutGrid } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { PLAYGROUND_PLATFORMS } from "@/lib/playground/catalog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { RoutedSectionGroup, RoutedSectionItem } from "../RoutedSectionShell";
|
||||
|
||||
interface PlaygroundSidebarProps {
|
||||
workspaceId: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function getPlaygroundNavItems(base: string): RoutedSectionItem[] {
|
||||
return [
|
||||
{
|
||||
value: "overview",
|
||||
label: "Overview",
|
||||
href: base,
|
||||
icon: <LayoutGrid className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
value: "runs",
|
||||
label: "API Runs",
|
||||
href: `${base}/runs`,
|
||||
icon: <History className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
value: "api-keys",
|
||||
label: "API Keys",
|
||||
href: `${base}/api-keys`,
|
||||
icon: <KeyRound className="h-4 w-4" />,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function getPlaygroundNavGroups(base: string): RoutedSectionGroup[] {
|
||||
return PLAYGROUND_PLATFORMS.map((platform) => {
|
||||
const Icon = platform.icon;
|
||||
return {
|
||||
value: platform.id,
|
||||
label: platform.label,
|
||||
icon: <Icon className="h-4 w-4 shrink-0" />,
|
||||
items: platform.verbs.map((verb) => ({
|
||||
value: `${platform.id}/${verb.verb}`,
|
||||
label: verb.label,
|
||||
href: `${base}/${platform.id}/${verb.verb}`,
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function getPlaygroundActiveValue(
|
||||
pathname: string | null,
|
||||
base: string,
|
||||
items: RoutedSectionItem[]
|
||||
): string {
|
||||
if (!pathname?.startsWith(base)) return "";
|
||||
|
||||
const rest = pathname.slice(base.length).replace(/^\/+/, "");
|
||||
if (!rest) return "overview";
|
||||
|
||||
const [first, second] = rest.split("/");
|
||||
if (second) return `${first}/${second}`;
|
||||
if (items.some((item) => item.value === first)) return first;
|
||||
|
||||
return "overview";
|
||||
}
|
||||
|
||||
export function getPlaygroundSelectedLabel(
|
||||
activeValue: string,
|
||||
items: RoutedSectionItem[],
|
||||
groups: RoutedSectionGroup[]
|
||||
): string {
|
||||
const topLevelItem = items.find((item) => item.value === activeValue);
|
||||
if (topLevelItem) return topLevelItem.label;
|
||||
|
||||
const group = groups.find((item) => item.items.some((child) => child.value === activeValue));
|
||||
const child = group?.items.find((item) => item.value === activeValue);
|
||||
|
||||
if (group && child) return `${group.label}: ${child.label}`;
|
||||
return "API Playground";
|
||||
}
|
||||
|
||||
function findActiveGroupValue(groups: RoutedSectionGroup[], activeValue: string): string | null {
|
||||
return (
|
||||
groups.find((group) => group.items.some((item) => item.value === activeValue))?.value ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function PlaygroundNavLink({
|
||||
item,
|
||||
activeValue,
|
||||
}: {
|
||||
item: RoutedSectionItem;
|
||||
activeValue: string;
|
||||
}) {
|
||||
const isActive = activeValue === item.value;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={item.href}
|
||||
replace
|
||||
scroll={false}
|
||||
prefetch
|
||||
className={cn(
|
||||
"inline-flex h-auto items-center justify-start gap-3 rounded-md px-3 py-2.5 text-left text-sm font-medium transition-colors duration-150 focus:outline-none focus-visible:outline-none",
|
||||
isActive
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
<span className="min-w-0 truncate">{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function PlaygroundNavGroup({
|
||||
group,
|
||||
activeValue,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
}: {
|
||||
group: RoutedSectionGroup;
|
||||
activeValue: string;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={isExpanded}
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
"inline-flex h-auto items-center justify-start gap-3 rounded-md px-3 py-2.5 text-left text-sm font-medium transition-colors duration-150 focus:outline-none focus-visible:outline-none",
|
||||
isExpanded
|
||||
? "text-accent-foreground"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
{group.icon}
|
||||
<span className="min-w-0 truncate">{group.label}</span>
|
||||
<ChevronRight
|
||||
className={cn("ml-auto h-4 w-4 shrink-0 transition-transform", isExpanded && "rotate-90")}
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className={cn(
|
||||
"grid overflow-hidden transition-[grid-template-rows,opacity] duration-200 ease-out",
|
||||
isExpanded ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
|
||||
)}
|
||||
aria-hidden={!isExpanded}
|
||||
>
|
||||
<div className="min-h-0 overflow-hidden">
|
||||
<div className="flex flex-col gap-0.5 pl-10">
|
||||
{group.items.map((item) => (
|
||||
<PlaygroundNavLink key={item.value} item={item} activeValue={activeValue} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlaygroundSidebar({ workspaceId, className }: PlaygroundSidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const base = `/dashboard/${workspaceId}/playground`;
|
||||
const items = useMemo(() => getPlaygroundNavItems(base), [base]);
|
||||
const groups = useMemo(() => getPlaygroundNavGroups(base), [base]);
|
||||
const activeValue = getPlaygroundActiveValue(pathname, base, items);
|
||||
const [expandedGroup, setExpandedGroup] = useState<string | null>(() =>
|
||||
findActiveGroupValue(groups, activeValue)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const activeGroup = findActiveGroupValue(groups, activeValue);
|
||||
if (activeGroup) {
|
||||
setExpandedGroup(activeGroup);
|
||||
}
|
||||
}, [activeValue, groups]);
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"flex h-full w-[240px] shrink-0 flex-col overflow-hidden border-r bg-panel text-sidebar-foreground select-none",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex h-12 shrink-0 items-center px-3">
|
||||
<h1 className="truncate text-lg font-semibold tracking-tight text-foreground">
|
||||
API Playground
|
||||
</h1>
|
||||
</div>
|
||||
<nav className="min-h-0 flex-1 overflow-y-auto px-2 pb-4 pt-1.5">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{items.map((item) => (
|
||||
<PlaygroundNavLink key={item.value} item={item} activeValue={activeValue} />
|
||||
))}
|
||||
<Separator className="my-3 bg-border" />
|
||||
{groups.map((group) => (
|
||||
<PlaygroundNavGroup
|
||||
key={group.value}
|
||||
group={group}
|
||||
activeValue={activeValue}
|
||||
isExpanded={expandedGroup === group.value}
|
||||
onToggle={() =>
|
||||
setExpandedGroup((current) => (current === group.value ? null : group.value))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,11 +2,11 @@
|
|||
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { PanelRight } from "lucide-react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { type MouseEvent, startTransition, useEffect } from "react";
|
||||
import { startTransition, useEffect } from "react";
|
||||
import { closeReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel.atom";
|
||||
import { citationPanelAtom, closeCitationPanelAtom } from "@/atoms/citation/citation-panel.atom";
|
||||
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
|
||||
import { closeEditorPanelAtom, editorPanelAtom } from "@/atoms/editor/editor-panel.atom";
|
||||
import {
|
||||
type RightPanelTab,
|
||||
|
|
@ -18,7 +18,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
|||
import { artifactsPanelOpenAtom, closeArtifactsPanelAtom } from "@/features/chat-artifacts";
|
||||
import { closeHitlEditPanelAtom, hitlEditPanelAtom } from "@/features/chat-messages/hitl";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DocumentsSidebar } from "../sidebar";
|
||||
|
||||
const EditorPanelContent = dynamic(
|
||||
() =>
|
||||
|
|
@ -36,6 +35,14 @@ const CitationPanelContent = dynamic(
|
|||
{ ssr: false, loading: () => null }
|
||||
);
|
||||
|
||||
const RunCitationPanelContent = dynamic(
|
||||
() =>
|
||||
import("@/components/citations/run-citation-panel").then((m) => ({
|
||||
default: m.RunCitationPanelContent,
|
||||
})),
|
||||
{ ssr: false, loading: () => null }
|
||||
);
|
||||
|
||||
const HitlEditPanelContent = dynamic(
|
||||
() =>
|
||||
import("@/features/chat-messages/hitl").then((m) => ({
|
||||
|
|
@ -61,41 +68,9 @@ const ArtifactsPanelContent = dynamic(
|
|||
);
|
||||
|
||||
interface RightPanelProps {
|
||||
documentsPanel?: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
showCollapseButton?: boolean;
|
||||
showTopBorder?: boolean;
|
||||
}
|
||||
|
||||
function isKeyboardClick(event: MouseEvent) {
|
||||
return event.detail === 0;
|
||||
}
|
||||
|
||||
function CollapseButton({ onClick }: { onClick: () => void }) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
tabIndex={-1}
|
||||
onClick={(event) => {
|
||||
if (isKeyboardClick(event)) return;
|
||||
onClick();
|
||||
}}
|
||||
className="h-8 w-8 shrink-0 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<PanelRight className="h-4 w-4" />
|
||||
<span className="sr-only">Collapse panel</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Collapse panel</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
interface RightPanelToggleButtonProps {
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
|
|
@ -108,7 +83,6 @@ export function RightPanelToggleButton({
|
|||
disabled = false,
|
||||
}: RightPanelToggleButtonProps) {
|
||||
const [collapsed, setCollapsed] = useAtom(rightPanelCollapsedAtom);
|
||||
const documentsOpen = useAtomValue(documentsSidebarOpenAtom);
|
||||
const reportState = useAtomValue(reportPanelAtom);
|
||||
const editorState = useAtomValue(editorPanelAtom);
|
||||
const hitlEditState = useAtomValue(hitlEditPanelAtom);
|
||||
|
|
@ -123,9 +97,8 @@ export function RightPanelToggleButton({
|
|||
? !!editorState.memoryScope
|
||||
: !!editorState.localFilePath);
|
||||
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
|
||||
const citationOpen = citationState.isOpen && citationState.chunkId != null;
|
||||
const hasContent =
|
||||
documentsOpen || reportOpen || editorOpen || hitlEditOpen || citationOpen || artifactsOpen;
|
||||
const citationOpen = citationState.isOpen && citationState.target != null;
|
||||
const hasContent = reportOpen || editorOpen || hitlEditOpen || citationOpen || artifactsOpen;
|
||||
const label = collapsed ? "Expand panel" : "Collapse panel";
|
||||
|
||||
if (!hasContent) return null;
|
||||
|
|
@ -162,7 +135,6 @@ export function RightPanelToggleButton({
|
|||
*/
|
||||
export function RightPanelExpandButton() {
|
||||
const [collapsed] = useAtom(rightPanelCollapsedAtom);
|
||||
const documentsOpen = useAtomValue(documentsSidebarOpenAtom);
|
||||
const reportState = useAtomValue(reportPanelAtom);
|
||||
const editorState = useAtomValue(editorPanelAtom);
|
||||
const hitlEditState = useAtomValue(hitlEditPanelAtom);
|
||||
|
|
@ -177,9 +149,8 @@ export function RightPanelExpandButton() {
|
|||
? !!editorState.memoryScope
|
||||
: !!editorState.localFilePath);
|
||||
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
|
||||
const citationOpen = citationState.isOpen && citationState.chunkId != null;
|
||||
const hasContent =
|
||||
documentsOpen || reportOpen || editorOpen || hitlEditOpen || citationOpen || artifactsOpen;
|
||||
const citationOpen = citationState.isOpen && citationState.target != null;
|
||||
const hasContent = reportOpen || editorOpen || hitlEditOpen || citationOpen || artifactsOpen;
|
||||
|
||||
if (!collapsed || !hasContent) return null;
|
||||
|
||||
|
|
@ -199,10 +170,14 @@ const PANEL_WIDTHS = {
|
|||
artifacts: 420,
|
||||
} as const;
|
||||
|
||||
const PANEL_SLIDE_TRANSITION = {
|
||||
duration: 0.2,
|
||||
ease: [0.22, 1, 0.36, 1],
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Priority order used to fall back to another open surface when the active
|
||||
* tab's content closes. Artifacts sit just above the always-available sources
|
||||
* tab.
|
||||
* tab's content closes. The neutral "sources" tab is kept as the closed state.
|
||||
*/
|
||||
const TAB_FALLBACK_ORDER: RightPanelTab[] = [
|
||||
"hitl-edit",
|
||||
|
|
@ -221,11 +196,7 @@ function resolveEffectiveTab(
|
|||
return TAB_FALLBACK_ORDER.find((tab) => openByTab[tab]) ?? "sources";
|
||||
}
|
||||
|
||||
export function RightPanel({
|
||||
documentsPanel,
|
||||
showCollapseButton = true,
|
||||
showTopBorder = false,
|
||||
}: RightPanelProps) {
|
||||
export function RightPanel({ showTopBorder = false }: RightPanelProps) {
|
||||
const [activeTab] = useAtom(rightPanelTabAtom);
|
||||
const reportState = useAtomValue(reportPanelAtom);
|
||||
const closeReport = useSetAtom(closeReportPanelAtom);
|
||||
|
|
@ -237,9 +208,9 @@ export function RightPanel({
|
|||
const closeCitation = useSetAtom(closeCitationPanelAtom);
|
||||
const artifactsOpen = useAtomValue(artifactsPanelOpenAtom);
|
||||
const closeArtifacts = useSetAtom(closeArtifactsPanelAtom);
|
||||
const [collapsed, setCollapsed] = useAtom(rightPanelCollapsedAtom);
|
||||
const [collapsed] = useAtom(rightPanelCollapsedAtom);
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
const documentsOpen = documentsPanel?.open ?? false;
|
||||
const reportOpen = reportState.isOpen && !!reportState.reportId;
|
||||
const editorOpen =
|
||||
editorState.isOpen &&
|
||||
|
|
@ -249,7 +220,7 @@ export function RightPanel({
|
|||
? !!editorState.memoryScope
|
||||
: !!editorState.localFilePath);
|
||||
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
|
||||
const citationOpen = citationState.isOpen && citationState.chunkId != null;
|
||||
const citationOpen = citationState.isOpen && citationState.target != null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!reportOpen && !editorOpen && !hitlEditOpen && !citationOpen && !artifactsOpen) return;
|
||||
|
|
@ -278,11 +249,10 @@ export function RightPanel({
|
|||
]);
|
||||
|
||||
const isVisible =
|
||||
(documentsOpen || reportOpen || editorOpen || hitlEditOpen || citationOpen || artifactsOpen) &&
|
||||
!collapsed;
|
||||
(reportOpen || editorOpen || hitlEditOpen || citationOpen || artifactsOpen) && !collapsed;
|
||||
|
||||
const effectiveTab = resolveEffectiveTab(activeTab, {
|
||||
sources: documentsOpen,
|
||||
sources: false,
|
||||
report: reportOpen,
|
||||
editor: editorOpen,
|
||||
"hitl-edit": hitlEditOpen,
|
||||
|
|
@ -291,78 +261,83 @@ export function RightPanel({
|
|||
});
|
||||
|
||||
const targetWidth = PANEL_WIDTHS[effectiveTab];
|
||||
const collapseButton = showCollapseButton ? (
|
||||
<CollapseButton onClick={() => setCollapsed(true)} />
|
||||
) : null;
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<aside
|
||||
style={{ width: targetWidth }}
|
||||
className={cn(
|
||||
"flex h-full shrink-0 flex-col border-l bg-panel text-sidebar-foreground overflow-hidden transition-[width] duration-200 ease-out",
|
||||
showTopBorder && "border-t"
|
||||
)}
|
||||
>
|
||||
<div className="relative flex-1 min-h-0 overflow-hidden">
|
||||
{effectiveTab === "sources" && documentsOpen && documentsPanel && (
|
||||
<div className="h-full">
|
||||
<DocumentsSidebar
|
||||
open={documentsPanel.open}
|
||||
onOpenChange={documentsPanel.onOpenChange}
|
||||
embedded
|
||||
headerAction={collapseButton}
|
||||
/>
|
||||
<AnimatePresence initial={false}>
|
||||
{isVisible ? (
|
||||
<motion.aside
|
||||
key="right-panel"
|
||||
initial={reduceMotion ? { width: targetWidth } : { width: 0, x: 24, opacity: 0 }}
|
||||
animate={{ width: targetWidth, x: 0, opacity: 1 }}
|
||||
exit={reduceMotion ? { width: 0 } : { width: 0, x: 24, opacity: 0 }}
|
||||
transition={reduceMotion ? { duration: 0 } : PANEL_SLIDE_TRANSITION}
|
||||
className={cn(
|
||||
"flex h-full shrink-0 flex-col overflow-hidden border-l bg-panel text-sidebar-foreground",
|
||||
showTopBorder && "border-t"
|
||||
)}
|
||||
>
|
||||
<div style={{ width: targetWidth }} className="flex h-full min-h-0 flex-col">
|
||||
<div className="relative flex-1 min-h-0 overflow-hidden">
|
||||
{effectiveTab === "report" && reportOpen && (
|
||||
<div className="h-full flex flex-col">
|
||||
<ReportPanelContent
|
||||
reportId={reportState.reportId as number}
|
||||
title={reportState.title || "Report"}
|
||||
onClose={closeReport}
|
||||
shareToken={reportState.shareToken}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{effectiveTab === "editor" && editorOpen && (
|
||||
<div className="h-full flex flex-col">
|
||||
<EditorPanelContent
|
||||
kind={editorState.kind}
|
||||
documentId={editorState.documentId ?? undefined}
|
||||
localFilePath={editorState.localFilePath ?? undefined}
|
||||
memoryScope={editorState.memoryScope ?? undefined}
|
||||
workspaceId={editorState.workspaceId ?? undefined}
|
||||
title={editorState.title}
|
||||
onClose={closeEditor}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{effectiveTab === "hitl-edit" && hitlEditOpen && hitlEditState.onSave && (
|
||||
<div className="h-full flex flex-col">
|
||||
<HitlEditPanelContent
|
||||
title={hitlEditState.title}
|
||||
content={hitlEditState.content}
|
||||
toolName={hitlEditState.toolName}
|
||||
contentFormat={hitlEditState.contentFormat}
|
||||
extraFields={hitlEditState.extraFields}
|
||||
onSave={hitlEditState.onSave}
|
||||
onClose={closeHitlEdit}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{effectiveTab === "citation" && citationOpen && citationState.target && (
|
||||
<div className="h-full flex flex-col">
|
||||
{citationState.target.kind === "run" ? (
|
||||
<RunCitationPanelContent
|
||||
runId={citationState.target.runId}
|
||||
onClose={closeCitation}
|
||||
/>
|
||||
) : (
|
||||
<CitationPanelContent
|
||||
chunkId={citationState.target.chunkId}
|
||||
onClose={closeCitation}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{effectiveTab === "artifacts" && artifactsOpen && (
|
||||
<div className="h-full flex flex-col">
|
||||
<ArtifactsPanelContent onClose={closeArtifacts} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{effectiveTab === "report" && reportOpen && (
|
||||
<div className="h-full flex flex-col">
|
||||
<ReportPanelContent
|
||||
reportId={reportState.reportId as number}
|
||||
title={reportState.title || "Report"}
|
||||
onClose={closeReport}
|
||||
shareToken={reportState.shareToken}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{effectiveTab === "editor" && editorOpen && (
|
||||
<div className="h-full flex flex-col">
|
||||
<EditorPanelContent
|
||||
kind={editorState.kind}
|
||||
documentId={editorState.documentId ?? undefined}
|
||||
localFilePath={editorState.localFilePath ?? undefined}
|
||||
memoryScope={editorState.memoryScope ?? undefined}
|
||||
searchSpaceId={editorState.searchSpaceId ?? undefined}
|
||||
title={editorState.title}
|
||||
onClose={closeEditor}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{effectiveTab === "hitl-edit" && hitlEditOpen && hitlEditState.onSave && (
|
||||
<div className="h-full flex flex-col">
|
||||
<HitlEditPanelContent
|
||||
title={hitlEditState.title}
|
||||
content={hitlEditState.content}
|
||||
toolName={hitlEditState.toolName}
|
||||
contentFormat={hitlEditState.contentFormat}
|
||||
extraFields={hitlEditState.extraFields}
|
||||
onSave={hitlEditState.onSave}
|
||||
onClose={closeHitlEdit}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{effectiveTab === "citation" && citationOpen && citationState.chunkId != null && (
|
||||
<div className="h-full flex flex-col">
|
||||
<CitationPanelContent chunkId={citationState.chunkId} onClose={closeCitation} />
|
||||
</div>
|
||||
)}
|
||||
{effectiveTab === "artifacts" && artifactsOpen && (
|
||||
<div className="h-full flex flex-col">
|
||||
<ArtifactsPanelContent onClose={closeArtifacts} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</motion.aside>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { activeTabAtom, type Tab } from "@/atoms/tabs/tabs.atom";
|
||||
import { useMemo, useState } from "react";
|
||||
import { activeTabIdAtom } from "@/atoms/tabs/tabs.atom";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import type { InboxItem } from "@/hooks/use-inbox";
|
||||
import { type ResolvedTab, useResolvedTabs } from "@/hooks/use-resolved-tabs";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { useElectronAPI } from "@/hooks/use-platform";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -18,7 +17,7 @@ import {
|
|||
SIDEBAR_MIN_WIDTH,
|
||||
useSidebarResize,
|
||||
} from "../../hooks/useSidebarResize";
|
||||
import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types";
|
||||
import type { ChatItem, NavItem, PageUsage, User, Workspace } from "../../types/layout.types";
|
||||
import { Header } from "../header";
|
||||
import { IconRail } from "../icon-rail";
|
||||
import {
|
||||
|
|
@ -26,16 +25,8 @@ import {
|
|||
RightPanelExpandButton,
|
||||
RightPanelToggleButton,
|
||||
} from "../right-panel/RightPanel";
|
||||
import {
|
||||
AllChatsSidebarContent,
|
||||
DocumentsSidebar,
|
||||
InboxSidebarContent,
|
||||
MobileSidebar,
|
||||
MobileSidebarTrigger,
|
||||
Sidebar,
|
||||
SidebarCollapseButton,
|
||||
} from "../sidebar";
|
||||
import { SidebarSlideOutPanel } from "../sidebar/SidebarSlideOutPanel";
|
||||
import { MobileSidebar, MobileSidebarTrigger, Sidebar, SidebarCollapseButton } from "../sidebar";
|
||||
import type { NotificationsDropdownData } from "../sidebar/NotificationsDropdown";
|
||||
import { TabBar } from "../tabs/TabBar";
|
||||
import { WorkspacePanel } from "./WorkspacePanel";
|
||||
|
||||
|
|
@ -51,6 +42,23 @@ const DocumentTabContent = dynamic(
|
|||
}
|
||||
);
|
||||
|
||||
const PLAYGROUND_SIDEBAR_COLLAPSED_COOKIE = "surfsense_playground_sidebar_collapsed";
|
||||
const PLAYGROUND_SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
|
||||
|
||||
function persistPlaygroundSidebarCollapsedCookie(isCollapsed: boolean) {
|
||||
void window.cookieStore
|
||||
?.set({
|
||||
name: PLAYGROUND_SIDEBAR_COLLAPSED_COOKIE,
|
||||
value: String(isCollapsed),
|
||||
path: "/",
|
||||
expires: Date.now() + PLAYGROUND_SIDEBAR_COOKIE_MAX_AGE * 1000,
|
||||
sameSite: "lax",
|
||||
})
|
||||
.catch(() => {
|
||||
// Ignore preference persistence failures.
|
||||
});
|
||||
}
|
||||
|
||||
function MacDesktopTitleBar({
|
||||
isSidebarCollapsed,
|
||||
onToggleSidebar,
|
||||
|
|
@ -81,36 +89,16 @@ function MacDesktopTitleBar({
|
|||
);
|
||||
}
|
||||
|
||||
// Per-tab data source
|
||||
interface TabDataSource {
|
||||
items: InboxItem[];
|
||||
unreadCount: number;
|
||||
loading: boolean;
|
||||
loadingMore: boolean;
|
||||
hasMore: boolean;
|
||||
loadMore: () => void;
|
||||
markAsRead: (id: number) => Promise<boolean>;
|
||||
markAllAsRead: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
export type ActiveSlideoutPanel = "inbox" | "chats" | null;
|
||||
|
||||
// Inbox-related props — per-tab data sources with independent loading/pagination
|
||||
interface InboxProps {
|
||||
isOpen: boolean;
|
||||
totalUnreadCount: number;
|
||||
comments: TabDataSource;
|
||||
status: TabDataSource;
|
||||
}
|
||||
|
||||
interface LayoutShellProps {
|
||||
searchSpaces: SearchSpace[];
|
||||
activeSearchSpaceId: number | null;
|
||||
onSearchSpaceSelect: (id: number) => void;
|
||||
onSearchSpaceDelete?: (searchSpace: SearchSpace) => void;
|
||||
onSearchSpaceSettings?: (searchSpace: SearchSpace) => void;
|
||||
onAddSearchSpace: () => void;
|
||||
searchSpace: SearchSpace | null;
|
||||
workspaces: Workspace[];
|
||||
activeWorkspaceId: number | null;
|
||||
onWorkspaceSelect: (id: number) => void;
|
||||
onWorkspaceDelete?: (workspace: Workspace) => void;
|
||||
onWorkspaceSettings?: (workspace: Workspace) => void;
|
||||
onAddWorkspace: () => void;
|
||||
isAtWorkspaceLimit?: boolean;
|
||||
maxWorkspacesPerUser?: number;
|
||||
workspace: Workspace | null;
|
||||
navItems: NavItem[];
|
||||
onNavItemClick?: (item: NavItem) => void;
|
||||
chats: ChatItem[];
|
||||
|
|
@ -121,6 +109,7 @@ interface LayoutShellProps {
|
|||
onChatRename?: (chat: ChatItem) => void;
|
||||
onChatDelete?: (chat: ChatItem) => void;
|
||||
onChatArchive?: (chat: ChatItem) => void;
|
||||
onChatsClick?: () => void;
|
||||
onViewAllChats?: () => void;
|
||||
user: User;
|
||||
onSettings?: () => void;
|
||||
|
|
@ -134,27 +123,19 @@ interface LayoutShellProps {
|
|||
setTheme?: (theme: "light" | "dark" | "system") => void;
|
||||
defaultCollapsed?: boolean;
|
||||
isChatPage?: boolean;
|
||||
isAllChatsPage?: boolean;
|
||||
showTabs?: boolean;
|
||||
useWorkspacePanel?: boolean;
|
||||
workspacePanelViewportClassName?: string;
|
||||
workspacePanelContentClassName?: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
// Unified slide-out panel state
|
||||
activeSlideoutPanel?: ActiveSlideoutPanel;
|
||||
onSlideoutPanelChange?: (panel: ActiveSlideoutPanel) => void;
|
||||
// Inbox props
|
||||
inbox?: InboxProps;
|
||||
notifications?: NotificationsDropdownData;
|
||||
isLoadingChats?: boolean;
|
||||
// All chats panel props
|
||||
allChatsPanel?: {
|
||||
searchSpaceId: string;
|
||||
};
|
||||
documentsPanel?: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
onTabSwitch?: (tab: Tab) => void;
|
||||
onTabPrefetch?: (tab: Tab) => void;
|
||||
onTabSwitch?: (tab: ResolvedTab) => void;
|
||||
onTabPrefetch?: (tab: ResolvedTab) => void;
|
||||
playgroundSidebar?: React.ReactNode;
|
||||
initialPlaygroundSidebarCollapsed?: boolean;
|
||||
}
|
||||
|
||||
function MainContentPanel({
|
||||
|
|
@ -162,19 +143,85 @@ function MainContentPanel({
|
|||
onTabSwitch,
|
||||
onTabPrefetch,
|
||||
onNewChat,
|
||||
showTabs = true,
|
||||
showRightPanelExpandButton = true,
|
||||
showTopBorder = false,
|
||||
children,
|
||||
}: {
|
||||
isChatPage: boolean;
|
||||
onTabSwitch?: (tab: Tab) => void;
|
||||
onTabPrefetch?: (tab: Tab) => void;
|
||||
onTabSwitch?: (tab: ResolvedTab) => void;
|
||||
onTabPrefetch?: (tab: ResolvedTab) => void;
|
||||
onNewChat?: () => void;
|
||||
showTabs?: boolean;
|
||||
showRightPanelExpandButton?: boolean;
|
||||
showTopBorder?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const activeTab = useAtomValue(activeTabAtom);
|
||||
if (!showTabs) {
|
||||
return (
|
||||
<UntabbedMainContentPanel isChatPage={isChatPage} showTopBorder={showTopBorder}>
|
||||
{children}
|
||||
</UntabbedMainContentPanel>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TabbedMainContentPanel
|
||||
isChatPage={isChatPage}
|
||||
onTabSwitch={onTabSwitch}
|
||||
onTabPrefetch={onTabPrefetch}
|
||||
onNewChat={onNewChat}
|
||||
showRightPanelExpandButton={showRightPanelExpandButton}
|
||||
showTopBorder={showTopBorder}
|
||||
>
|
||||
{children}
|
||||
</TabbedMainContentPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function UntabbedMainContentPanel({
|
||||
isChatPage,
|
||||
showTopBorder,
|
||||
children,
|
||||
}: {
|
||||
isChatPage: boolean;
|
||||
showTopBorder: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn("relative isolate flex flex-1 flex-col min-w-0", showTopBorder && "border-t")}
|
||||
>
|
||||
<div className="relative flex flex-1 flex-col bg-panel overflow-hidden min-w-0">
|
||||
<Header />
|
||||
<div className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TabbedMainContentPanel({
|
||||
isChatPage,
|
||||
onTabSwitch,
|
||||
onTabPrefetch,
|
||||
onNewChat,
|
||||
showRightPanelExpandButton,
|
||||
showTopBorder,
|
||||
children,
|
||||
}: {
|
||||
isChatPage: boolean;
|
||||
onTabSwitch?: (tab: ResolvedTab) => void;
|
||||
onTabPrefetch?: (tab: ResolvedTab) => void;
|
||||
onNewChat?: () => void;
|
||||
showRightPanelExpandButton: boolean;
|
||||
showTopBorder: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const activeTabId = useAtomValue(activeTabIdAtom);
|
||||
const tabs = useResolvedTabs();
|
||||
const activeTab = tabs.find((tab) => tab.id === activeTabId) ?? null;
|
||||
const isDocumentTab = activeTab?.type === "document";
|
||||
|
||||
return (
|
||||
|
|
@ -191,12 +238,12 @@ function MainContentPanel({
|
|||
<div className="relative flex flex-1 flex-col bg-panel overflow-hidden min-w-0">
|
||||
<Header />
|
||||
|
||||
{isDocumentTab && activeTab.documentId && activeTab.searchSpaceId ? (
|
||||
{isDocumentTab && activeTab.entityId && activeTab.workspaceId ? (
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<DocumentTabContent
|
||||
key={activeTab.documentId}
|
||||
documentId={activeTab.documentId}
|
||||
searchSpaceId={activeTab.searchSpaceId}
|
||||
key={activeTab.entityId}
|
||||
documentId={activeTab.entityId}
|
||||
workspaceId={activeTab.workspaceId}
|
||||
title={activeTab.title}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -215,13 +262,15 @@ function DesktopWorkspaceRegion({ children }: { children: React.ReactNode }) {
|
|||
}
|
||||
|
||||
export function LayoutShell({
|
||||
searchSpaces,
|
||||
activeSearchSpaceId,
|
||||
onSearchSpaceSelect,
|
||||
onSearchSpaceDelete,
|
||||
onSearchSpaceSettings,
|
||||
onAddSearchSpace,
|
||||
searchSpace,
|
||||
workspaces,
|
||||
activeWorkspaceId,
|
||||
onWorkspaceSelect,
|
||||
onWorkspaceDelete,
|
||||
onWorkspaceSettings,
|
||||
onAddWorkspace,
|
||||
isAtWorkspaceLimit = false,
|
||||
maxWorkspacesPerUser,
|
||||
workspace,
|
||||
navItems,
|
||||
onNavItemClick,
|
||||
chats,
|
||||
|
|
@ -232,6 +281,7 @@ export function LayoutShell({
|
|||
onChatRename,
|
||||
onChatDelete,
|
||||
onChatArchive,
|
||||
onChatsClick,
|
||||
onViewAllChats,
|
||||
user,
|
||||
onSettings,
|
||||
|
|
@ -245,24 +295,27 @@ export function LayoutShell({
|
|||
setTheme,
|
||||
defaultCollapsed = false,
|
||||
isChatPage = false,
|
||||
isAllChatsPage = false,
|
||||
showTabs = true,
|
||||
useWorkspacePanel = false,
|
||||
workspacePanelViewportClassName,
|
||||
workspacePanelContentClassName,
|
||||
children,
|
||||
className,
|
||||
activeSlideoutPanel = null,
|
||||
onSlideoutPanelChange,
|
||||
inbox,
|
||||
notifications,
|
||||
isLoadingChats = false,
|
||||
allChatsPanel,
|
||||
documentsPanel,
|
||||
onTabSwitch,
|
||||
onTabPrefetch,
|
||||
playgroundSidebar,
|
||||
initialPlaygroundSidebarCollapsed = false,
|
||||
}: LayoutShellProps) {
|
||||
const isMobile = useIsMobile();
|
||||
const electronAPI = useElectronAPI();
|
||||
const isMacDesktop = electronAPI?.versions.platform === "darwin";
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [isPlaygroundSidebarCollapsed, setIsPlaygroundSidebarCollapsed] = useState(
|
||||
initialPlaygroundSidebarCollapsed
|
||||
);
|
||||
const { isCollapsed, setIsCollapsed, toggleCollapsed } = useSidebarState(defaultCollapsed);
|
||||
const {
|
||||
sidebarWidth,
|
||||
|
|
@ -275,18 +328,13 @@ export function LayoutShell({
|
|||
() => ({ isCollapsed, setIsCollapsed, toggleCollapsed }),
|
||||
[isCollapsed, setIsCollapsed, toggleCollapsed]
|
||||
);
|
||||
|
||||
const closeSlideout = useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open) onSlideoutPanelChange?.(null);
|
||||
},
|
||||
[onSlideoutPanelChange]
|
||||
);
|
||||
|
||||
const anySlideOutOpen = activeSlideoutPanel !== null;
|
||||
|
||||
const panelAriaLabel =
|
||||
activeSlideoutPanel === "inbox" ? "Inbox" : activeSlideoutPanel === "chats" ? "Chats" : "Panel";
|
||||
const handlePlaygroundSidebarToggle = () => {
|
||||
setIsPlaygroundSidebarCollapsed((collapsed) => {
|
||||
const nextCollapsed = !collapsed;
|
||||
persistPlaygroundSidebarCollapsedCookie(nextCollapsed);
|
||||
return nextCollapsed;
|
||||
});
|
||||
};
|
||||
|
||||
// Mobile layout
|
||||
if (isMobile) {
|
||||
|
|
@ -301,11 +349,13 @@ export function LayoutShell({
|
|||
<MobileSidebar
|
||||
isOpen={mobileMenuOpen}
|
||||
onOpenChange={setMobileMenuOpen}
|
||||
searchSpaces={searchSpaces}
|
||||
activeSearchSpaceId={activeSearchSpaceId}
|
||||
onSearchSpaceSelect={onSearchSpaceSelect}
|
||||
onAddSearchSpace={onAddSearchSpace}
|
||||
searchSpace={searchSpace}
|
||||
workspaces={workspaces}
|
||||
activeWorkspaceId={activeWorkspaceId}
|
||||
onWorkspaceSelect={onWorkspaceSelect}
|
||||
onAddWorkspace={onAddWorkspace}
|
||||
isAtWorkspaceLimit={isAtWorkspaceLimit}
|
||||
maxWorkspacesPerUser={maxWorkspacesPerUser}
|
||||
workspace={workspace}
|
||||
navItems={navItems}
|
||||
onNavItemClick={onNavItemClick}
|
||||
chats={chats}
|
||||
|
|
@ -316,14 +366,16 @@ export function LayoutShell({
|
|||
onChatRename={onChatRename}
|
||||
onChatDelete={onChatDelete}
|
||||
onChatArchive={onChatArchive}
|
||||
onChatsClick={onChatsClick}
|
||||
onViewAllChats={onViewAllChats}
|
||||
isChatsPanelOpen={activeSlideoutPanel === "chats"}
|
||||
isAllChatsActive={isAllChatsPage}
|
||||
user={user}
|
||||
onSettings={onSettings}
|
||||
onManageMembers={onManageMembers}
|
||||
onUserSettings={onUserSettings}
|
||||
onAnnouncements={onAnnouncements}
|
||||
announcementUnreadCount={announcementUnreadCount}
|
||||
notifications={notifications}
|
||||
onLogout={onLogout}
|
||||
pageUsage={pageUsage}
|
||||
theme={theme}
|
||||
|
|
@ -343,58 +395,6 @@ export function LayoutShell({
|
|||
{children}
|
||||
</main>
|
||||
)}
|
||||
|
||||
{/* Mobile unified slide-out panel */}
|
||||
<SidebarSlideOutPanel
|
||||
open={anySlideOutOpen}
|
||||
onOpenChange={closeSlideout}
|
||||
ariaLabel={panelAriaLabel}
|
||||
>
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
{activeSlideoutPanel === "inbox" && inbox && (
|
||||
<motion.div
|
||||
key="inbox"
|
||||
className="h-full flex flex-col"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<InboxSidebarContent
|
||||
onOpenChange={(open) => closeSlideout(open)}
|
||||
comments={inbox.comments}
|
||||
status={inbox.status}
|
||||
totalUnreadCount={inbox.totalUnreadCount}
|
||||
onCloseMobileSidebar={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
{activeSlideoutPanel === "chats" && allChatsPanel && (
|
||||
<motion.div
|
||||
key="chats"
|
||||
className="h-full flex flex-col"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<AllChatsSidebarContent
|
||||
onOpenChange={(open) => closeSlideout(open)}
|
||||
searchSpaceId={allChatsPanel.searchSpaceId}
|
||||
onCloseMobileSidebar={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</SidebarSlideOutPanel>
|
||||
|
||||
{/* Mobile Documents Sidebar - separate (not part of slide-out group) */}
|
||||
{documentsPanel && (
|
||||
<DocumentsSidebar
|
||||
open={documentsPanel.open}
|
||||
onOpenChange={documentsPanel.onOpenChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</SidebarProvider>
|
||||
|
|
@ -421,17 +421,20 @@ export function LayoutShell({
|
|||
)}
|
||||
>
|
||||
<IconRail
|
||||
searchSpaces={searchSpaces}
|
||||
activeSearchSpaceId={activeSearchSpaceId}
|
||||
onSearchSpaceSelect={onSearchSpaceSelect}
|
||||
onSearchSpaceDelete={onSearchSpaceDelete}
|
||||
onSearchSpaceSettings={onSearchSpaceSettings}
|
||||
onAddSearchSpace={onAddSearchSpace}
|
||||
workspaces={workspaces}
|
||||
activeWorkspaceId={activeWorkspaceId}
|
||||
onWorkspaceSelect={onWorkspaceSelect}
|
||||
onWorkspaceDelete={onWorkspaceDelete}
|
||||
onWorkspaceSettings={onWorkspaceSettings}
|
||||
onAddWorkspace={onAddWorkspace}
|
||||
isAtWorkspaceLimit={isAtWorkspaceLimit}
|
||||
maxWorkspacesPerUser={maxWorkspacesPerUser}
|
||||
isSingleRailMode={false}
|
||||
user={user}
|
||||
onUserSettings={onUserSettings}
|
||||
onAnnouncements={onAnnouncements}
|
||||
announcementUnreadCount={announcementUnreadCount}
|
||||
notifications={notifications}
|
||||
onLogout={onLogout}
|
||||
theme={theme}
|
||||
setTheme={setTheme}
|
||||
|
|
@ -442,15 +445,21 @@ export function LayoutShell({
|
|||
<div
|
||||
className={cn(
|
||||
"relative hidden md:flex shrink-0 z-20 -mr-2 bg-panel",
|
||||
isMacDesktop ? "rounded-tl-xl border-t border-r border-l" : "border-r"
|
||||
isMacDesktop ? "rounded-tl-xl border-l border-t border-r" : "border-r"
|
||||
)}
|
||||
>
|
||||
<Sidebar
|
||||
searchSpace={searchSpace}
|
||||
workspace={workspace}
|
||||
isCollapsed={isCollapsed}
|
||||
onToggleCollapse={toggleCollapsed}
|
||||
navItems={navItems}
|
||||
onNavItemClick={onNavItemClick}
|
||||
onPlaygroundItemClick={
|
||||
playgroundSidebar ? handlePlaygroundSidebarToggle : undefined
|
||||
}
|
||||
isPlaygroundSidebarOpen={
|
||||
playgroundSidebar ? !isPlaygroundSidebarCollapsed : undefined
|
||||
}
|
||||
chats={chats}
|
||||
activeChatId={activeChatId}
|
||||
onNewChat={onNewChat}
|
||||
|
|
@ -459,8 +468,9 @@ export function LayoutShell({
|
|||
onChatRename={onChatRename}
|
||||
onChatDelete={onChatDelete}
|
||||
onChatArchive={onChatArchive}
|
||||
onChatsClick={onChatsClick}
|
||||
onViewAllChats={onViewAllChats}
|
||||
isChatsPanelOpen={activeSlideoutPanel === "chats"}
|
||||
isAllChatsActive={isAllChatsPage}
|
||||
user={user}
|
||||
onSettings={onSettings}
|
||||
onManageMembers={onManageMembers}
|
||||
|
|
@ -501,50 +511,23 @@ export function LayoutShell({
|
|||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Unified slide-out panel — shell stays open, content cross-fades */}
|
||||
<SidebarSlideOutPanel
|
||||
open={anySlideOutOpen}
|
||||
onOpenChange={closeSlideout}
|
||||
ariaLabel={panelAriaLabel}
|
||||
>
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
{activeSlideoutPanel === "inbox" && inbox && (
|
||||
<motion.div
|
||||
key="inbox"
|
||||
className="h-full flex flex-col"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<InboxSidebarContent
|
||||
onOpenChange={(open) => closeSlideout(open)}
|
||||
comments={inbox.comments}
|
||||
status={inbox.status}
|
||||
totalUnreadCount={inbox.totalUnreadCount}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
{activeSlideoutPanel === "chats" && allChatsPanel && (
|
||||
<motion.div
|
||||
key="chats"
|
||||
className="h-full flex flex-col"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<AllChatsSidebarContent
|
||||
onOpenChange={(open) => closeSlideout(open)}
|
||||
searchSpaceId={allChatsPanel.searchSpaceId}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</SidebarSlideOutPanel>
|
||||
</div>
|
||||
|
||||
{playgroundSidebar ? (
|
||||
<div
|
||||
aria-hidden={isPlaygroundSidebarCollapsed}
|
||||
className={cn(
|
||||
"hidden md:flex shrink-0 overflow-hidden -mr-2 bg-panel transition-[width,opacity] duration-200 ease-out",
|
||||
isPlaygroundSidebarCollapsed
|
||||
? "w-0 opacity-0 pointer-events-none"
|
||||
: "w-[240px] opacity-100",
|
||||
isMacDesktop && !isPlaygroundSidebarCollapsed && "border-t"
|
||||
)}
|
||||
>
|
||||
<div className="w-[240px] shrink-0">{playgroundSidebar}</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<DesktopWorkspaceRegion>
|
||||
{useWorkspacePanel ? (
|
||||
<WorkspacePanel
|
||||
|
|
@ -562,23 +545,15 @@ export function LayoutShell({
|
|||
onTabSwitch={onTabSwitch}
|
||||
onTabPrefetch={onTabPrefetch}
|
||||
onNewChat={onNewChat}
|
||||
showTabs={showTabs}
|
||||
showRightPanelExpandButton={!isMacDesktop}
|
||||
showTopBorder={isMacDesktop}
|
||||
>
|
||||
{children}
|
||||
</MainContentPanel>
|
||||
|
||||
{/* Right panel — tabbed Sources/Report (desktop only) */}
|
||||
{documentsPanel ? (
|
||||
<RightPanel
|
||||
documentsPanel={{
|
||||
open: documentsPanel.open,
|
||||
onOpenChange: documentsPanel.onOpenChange,
|
||||
}}
|
||||
showCollapseButton={!isMacDesktop}
|
||||
showTopBorder={isMacDesktop}
|
||||
/>
|
||||
) : null}
|
||||
{/* Right panel — Report/Editor/Citations/Artifacts (desktop only) */}
|
||||
<RightPanel showTopBorder={isMacDesktop} />
|
||||
</>
|
||||
)}
|
||||
</DesktopWorkspaceRegion>
|
||||
|
|
|
|||
|
|
@ -4,15 +4,13 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|||
import { useSetAtom } from "jotai";
|
||||
import {
|
||||
ArchiveIcon,
|
||||
ChevronLeft,
|
||||
MessageCircleMore,
|
||||
Check,
|
||||
ChevronDown,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
RotateCcwIcon,
|
||||
Search,
|
||||
Trash2,
|
||||
User,
|
||||
Users,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
|
|
@ -20,7 +18,7 @@ import { useTranslations } from "next-intl";
|
|||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { removeChatTabAtom } from "@/atoms/tabs/tabs.atom";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/animated-tabs";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -39,32 +37,22 @@ import {
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useActivateChatThread } from "@/hooks/use-activate-chat-thread";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import { useLongPress } from "@/hooks/use-long-press";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { getChatUrl } from "@/hooks/use-resolved-tabs";
|
||||
import { useArchiveThread, useDeleteThread, useRenameThread } from "@/hooks/use-thread-mutations";
|
||||
import { fetchThreads, searchThreads, type ThreadListItem } from "@/lib/chat/thread-persistence";
|
||||
import { formatThreadTimestamp } from "@/lib/format-date";
|
||||
import { formatRelativeDate } from "@/lib/format-date";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
|
||||
|
||||
export interface AllChatsSidebarContentProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
searchSpaceId: string;
|
||||
onCloseMobileSidebar?: () => void;
|
||||
interface AllChatsContentProps {
|
||||
workspaceId: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface AllChatsSidebarProps extends AllChatsSidebarContentProps {
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
export function AllChatsSidebarContent({
|
||||
onOpenChange,
|
||||
searchSpaceId,
|
||||
onCloseMobileSidebar,
|
||||
}: AllChatsSidebarContentProps) {
|
||||
function AllChatsContent({ workspaceId, className }: AllChatsContentProps) {
|
||||
const t = useTranslations("sidebar");
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
|
|
@ -72,9 +60,9 @@ export function AllChatsSidebarContent({
|
|||
const isMobile = useIsMobile();
|
||||
const removeChatTab = useSetAtom(removeChatTabAtom);
|
||||
const { activateChatThread, prefetchChatThread } = useActivateChatThread();
|
||||
const { mutateAsync: deleteThread } = useDeleteThread(searchSpaceId);
|
||||
const { mutateAsync: archiveThread } = useArchiveThread(searchSpaceId);
|
||||
const { mutateAsync: renameThread } = useRenameThread(searchSpaceId);
|
||||
const { mutateAsync: deleteThread } = useDeleteThread(workspaceId);
|
||||
const { mutateAsync: archiveThread } = useArchiveThread(workspaceId);
|
||||
const { mutateAsync: renameThread } = useRenameThread(workspaceId);
|
||||
|
||||
const currentChatId = Array.isArray(params.chat_id)
|
||||
? Number(params.chat_id[0])
|
||||
|
|
@ -106,12 +94,14 @@ export function AllChatsSidebarContent({
|
|||
const {
|
||||
data: threadsData,
|
||||
error: threadsError,
|
||||
isFetching: isFetchingThreads,
|
||||
isLoading: isLoadingThreads,
|
||||
isPlaceholderData,
|
||||
} = useQuery({
|
||||
queryKey: ["all-threads", searchSpaceId],
|
||||
queryFn: () => fetchThreads(Number(searchSpaceId)),
|
||||
enabled: !!searchSpaceId && !isSearchMode,
|
||||
placeholderData: () => queryClient.getQueryData(["threads", searchSpaceId, { limit: 40 }]),
|
||||
queryKey: ["all-threads", workspaceId],
|
||||
queryFn: () => fetchThreads(Number(workspaceId)),
|
||||
enabled: !!workspaceId && !isSearchMode,
|
||||
placeholderData: () => queryClient.getQueryData(["threads", workspaceId, { limit: 6 }]),
|
||||
});
|
||||
|
||||
const {
|
||||
|
|
@ -119,41 +109,31 @@ export function AllChatsSidebarContent({
|
|||
error: searchError,
|
||||
isLoading: isLoadingSearch,
|
||||
} = useQuery({
|
||||
queryKey: ["search-threads", searchSpaceId, debouncedSearchQuery],
|
||||
queryFn: () => searchThreads(Number(searchSpaceId), debouncedSearchQuery.trim()),
|
||||
enabled: !!searchSpaceId && isSearchMode,
|
||||
queryKey: ["search-threads", workspaceId, debouncedSearchQuery],
|
||||
queryFn: () => searchThreads(Number(workspaceId), debouncedSearchQuery.trim()),
|
||||
enabled: !!workspaceId && isSearchMode,
|
||||
});
|
||||
|
||||
const { activeChats, archivedChats } = useMemo(() => {
|
||||
const threads = useMemo(() => {
|
||||
if (isSearchMode) {
|
||||
return {
|
||||
activeChats: (searchData ?? []).filter((t) => !t.archived),
|
||||
archivedChats: (searchData ?? []).filter((t) => t.archived),
|
||||
};
|
||||
return (searchData ?? []).filter((thread) => thread.archived === showArchived);
|
||||
}
|
||||
|
||||
if (!threadsData) return { activeChats: [], archivedChats: [] };
|
||||
if (!threadsData) return [];
|
||||
|
||||
return {
|
||||
activeChats: threadsData.threads,
|
||||
archivedChats: threadsData.archived_threads,
|
||||
};
|
||||
}, [threadsData, searchData, isSearchMode]);
|
||||
|
||||
const threads = showArchived ? archivedChats : activeChats;
|
||||
return showArchived ? threadsData.archived_threads : threadsData.threads;
|
||||
}, [threadsData, searchData, isSearchMode, showArchived]);
|
||||
|
||||
const handleThreadClick = useCallback(
|
||||
(thread: ThreadListItem) => {
|
||||
activateChatThread({
|
||||
id: thread.id,
|
||||
title: thread.title || "New Chat",
|
||||
searchSpaceId,
|
||||
workspaceId,
|
||||
visibility: thread.visibility,
|
||||
});
|
||||
onOpenChange(false);
|
||||
onCloseMobileSidebar?.();
|
||||
},
|
||||
[activateChatThread, onOpenChange, searchSpaceId, onCloseMobileSidebar]
|
||||
[activateChatThread, workspaceId]
|
||||
);
|
||||
|
||||
const handleDeleteThread = useCallback(
|
||||
|
|
@ -165,28 +145,17 @@ export function AllChatsSidebarContent({
|
|||
toast.success(t("chat_deleted") || "Chat deleted successfully");
|
||||
|
||||
if (currentChatId === threadId) {
|
||||
onOpenChange(false);
|
||||
setTimeout(() => {
|
||||
if (
|
||||
fallbackTab?.type === "chat" &&
|
||||
fallbackTab.chatUrl &&
|
||||
fallbackTab.chatId !== undefined
|
||||
) {
|
||||
if (fallbackTab?.type === "chat") {
|
||||
const fallbackWorkspaceId = fallbackTab.workspaceId || Number(workspaceId);
|
||||
activateChatThread({
|
||||
id: fallbackTab.chatId ?? null,
|
||||
title: fallbackTab.title,
|
||||
url: fallbackTab.chatUrl,
|
||||
searchSpaceId: fallbackTab.searchSpaceId ?? searchSpaceId,
|
||||
...(fallbackTab.visibility !== undefined
|
||||
? { visibility: fallbackTab.visibility }
|
||||
: {}),
|
||||
...(fallbackTab.hasComments !== undefined
|
||||
? { hasComments: fallbackTab.hasComments }
|
||||
: {}),
|
||||
id: fallbackTab.entityId,
|
||||
url: getChatUrl(fallbackWorkspaceId, fallbackTab.entityId),
|
||||
workspaceId: fallbackWorkspaceId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
||||
router.push(`/dashboard/${workspaceId}/new-chat`);
|
||||
}, 250);
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -196,16 +165,7 @@ export function AllChatsSidebarContent({
|
|||
setDeletingThreadId(null);
|
||||
}
|
||||
},
|
||||
[
|
||||
activateChatThread,
|
||||
deleteThread,
|
||||
t,
|
||||
currentChatId,
|
||||
router,
|
||||
onOpenChange,
|
||||
removeChatTab,
|
||||
searchSpaceId,
|
||||
]
|
||||
[activateChatThread, deleteThread, t, currentChatId, router, removeChatTab, workspaceId]
|
||||
);
|
||||
|
||||
const handleToggleArchive = useCallback(
|
||||
|
|
@ -260,91 +220,92 @@ export function AllChatsSidebarContent({
|
|||
}, []);
|
||||
|
||||
const isLoading = isSearchMode ? isLoadingSearch : isLoadingThreads;
|
||||
const isLoadingMoreThreads = !isSearchMode && isFetchingThreads && isPlaceholderData;
|
||||
const error = isSearchMode ? searchError : threadsError;
|
||||
|
||||
const activeCount = activeChats.length;
|
||||
const archivedCount = archivedChats.length;
|
||||
const selectedFilterLabel = showArchived ? "Archived" : "Active";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="shrink-0 p-3 pb-1.5 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{isMobile && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full text-muted-foreground hover:text-accent-foreground"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span className="sr-only">{t("close") || "Close"}</span>
|
||||
</Button>
|
||||
<div className={cn("flex h-full min-h-0 w-full flex-1 flex-col", className)}>
|
||||
<div className="shrink-0 space-y-4 px-3 pb-3">
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<h1 className="text-xl md:text-2xl font-semibold text-foreground">
|
||||
{t("chats") || "Chats"}
|
||||
</h1>
|
||||
</div>
|
||||
{!isSearchMode && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="h-7 gap-1.5 rounded-md px-2.5 text-xs font-medium"
|
||||
>
|
||||
<span className="font-semibold text-muted-foreground">Filter by</span>
|
||||
<span>{selectedFilterLabel}</span>
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-32">
|
||||
<DropdownMenuItem onClick={() => setShowArchived(false)}>
|
||||
<span className="flex-1">Active</span>
|
||||
<Check
|
||||
className={cn(
|
||||
"h-4 w-4 text-primary",
|
||||
showArchived ? "opacity-0" : "opacity-100"
|
||||
)}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setShowArchived(true)}>
|
||||
<span className="flex-1">Archived</span>
|
||||
<Check
|
||||
className={cn(
|
||||
"h-4 w-4 text-primary",
|
||||
showArchived ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<h2 className="text-lg font-semibold">{t("chats") || "Chats"}</h2>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 h-4.5 w-4.5 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t("search_chats") || "Search chats..."}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-8 border-0 bg-muted pl-8 pr-7 text-sm shadow-none"
|
||||
className="h-10 border-0 bg-muted pl-10 pr-9 text-base shadow-none"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1/2 h-5 w-5 -translate-y-1/2 rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
className="absolute right-2 top-1/2 h-7 w-7 -translate-y-1/2 rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
onClick={handleClearSearch}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
<X className="h-4.5 w-4.5" />
|
||||
<span className="sr-only">{t("clear_search") || "Clear search"}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isSearchMode && (
|
||||
<Tabs
|
||||
value={showArchived ? "archived" : "active"}
|
||||
onValueChange={(value) => setShowArchived(value === "archived")}
|
||||
className="shrink-0 mx-3 mt-1.5"
|
||||
>
|
||||
<TabsList stretch showBottomBorder size="sm">
|
||||
<TabsTrigger value="active">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<MessageCircleMore className="h-3.5 w-3.5" />
|
||||
<span>Active</span>
|
||||
<span className="inline-flex h-4.5 min-w-4.5 items-center justify-center rounded-full bg-primary/20 px-1 text-[10px] font-medium text-muted-foreground">
|
||||
{activeCount}
|
||||
</span>
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="archived">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ArchiveIcon className="h-3.5 w-3.5" />
|
||||
<span>Archived</span>
|
||||
<span className="inline-flex h-4.5 min-w-4.5 items-center justify-center rounded-full bg-primary/20 px-1 text-[10px] font-medium text-muted-foreground">
|
||||
{archivedCount}
|
||||
</span>
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-1.5">
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden px-3 py-1.5">
|
||||
{isLoading ? (
|
||||
<div className="space-y-1">
|
||||
{[75, 90, 55, 80, 65, 85].map((titleWidth) => (
|
||||
<div
|
||||
key={`skeleton-${titleWidth}`}
|
||||
className="flex items-center gap-2 rounded-md px-2 py-1.5"
|
||||
className="rounded-md px-3 py-1.5 md:py-2"
|
||||
>
|
||||
<Skeleton className="h-4 w-4 shrink-0 rounded" />
|
||||
<Skeleton className="h-4 rounded" style={{ width: `${titleWidth}%` }} />
|
||||
<Skeleton
|
||||
className="h-4 rounded md:h-4.5"
|
||||
style={{ width: `${titleWidth}%` }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -353,15 +314,21 @@ export function AllChatsSidebarContent({
|
|||
{t("error_loading_chats") || "Error loading chats"}
|
||||
</div>
|
||||
) : threads.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{threads.map((thread) => {
|
||||
<div>
|
||||
{threads.map((thread, index) => {
|
||||
const isDeleting = deletingThreadId === thread.id;
|
||||
const isArchiving = archivingThreadId === thread.id;
|
||||
const isBusy = isDeleting || isArchiving;
|
||||
const isActive = currentChatId === thread.id;
|
||||
|
||||
return (
|
||||
<div key={thread.id} className="group/item relative w-full">
|
||||
<div
|
||||
key={thread.id}
|
||||
className={cn(
|
||||
"group/item relative w-full hover:border-t-transparent [&:hover+div]:border-t-transparent",
|
||||
index > 0 && "border-t border-border/60"
|
||||
)}
|
||||
>
|
||||
{isMobile ? (
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -380,10 +347,10 @@ export function AllChatsSidebarContent({
|
|||
onTouchMove={longPressHandlers.onTouchMove}
|
||||
disabled={isBusy}
|
||||
className={cn(
|
||||
"h-auto w-full justify-start gap-2 overflow-hidden px-2 py-1.5 text-left font-normal",
|
||||
"h-auto w-full justify-start gap-2.5 overflow-hidden px-3 py-2.5 text-left text-base font-normal",
|
||||
"group-hover/item:bg-accent group-hover/item:text-accent-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
thread.visibility === "SEARCH_SPACE" && "pr-9",
|
||||
thread.visibility === "SEARCH_SPACE" ? "pr-44" : "pr-36",
|
||||
isActive && "bg-accent text-accent-foreground",
|
||||
isBusy && "opacity-50 pointer-events-none"
|
||||
)}
|
||||
|
|
@ -391,63 +358,56 @@ export function AllChatsSidebarContent({
|
|||
<span className="min-w-0 flex-1 truncate">{thread.title || "New Chat"}</span>
|
||||
</Button>
|
||||
) : (
|
||||
<Tooltip delayDuration={600}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => handleThreadClick(thread)}
|
||||
onMouseEnter={() => prefetchChatThread(thread.id)}
|
||||
onFocus={() => prefetchChatThread(thread.id)}
|
||||
disabled={isBusy}
|
||||
className={cn(
|
||||
"h-auto w-full justify-start gap-2 overflow-hidden px-2 py-1.5 text-left font-normal",
|
||||
"group-hover/item:bg-accent group-hover/item:text-accent-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
thread.visibility === "SEARCH_SPACE" && "pr-9",
|
||||
isActive && "bg-accent text-accent-foreground",
|
||||
isBusy && "opacity-50 pointer-events-none"
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{thread.title || "New Chat"}
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="start">
|
||||
<p>
|
||||
{t("updated") || "Updated"}: {formatThreadTimestamp(thread.updatedAt)}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => handleThreadClick(thread)}
|
||||
onMouseEnter={() => prefetchChatThread(thread.id)}
|
||||
onFocus={() => prefetchChatThread(thread.id)}
|
||||
disabled={isBusy}
|
||||
className={cn(
|
||||
"h-auto w-full justify-start gap-2.5 overflow-hidden px-3 py-2.5 text-left text-base font-normal",
|
||||
"group-hover/item:bg-accent group-hover/item:text-accent-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
thread.visibility === "SEARCH_SPACE" ? "pr-44" : "pr-36",
|
||||
isActive && "bg-accent text-accent-foreground",
|
||||
isBusy && "opacity-50 pointer-events-none"
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{thread.title || "New Chat"}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute right-0 top-0 bottom-0 flex items-center rounded-r-md pl-6 pr-1",
|
||||
"pointer-events-none absolute inset-y-0 right-0 flex items-center rounded-r-md pl-6 pr-1",
|
||||
isActive
|
||||
? "bg-gradient-to-l from-accent from-60% to-transparent"
|
||||
: "bg-gradient-to-l from-sidebar from-60% to-transparent group-hover/item:from-accent",
|
||||
isMobile
|
||||
? "opacity-0"
|
||||
: thread.visibility === "SEARCH_SPACE" || openDropdownId === thread.id
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover/item:opacity-100"
|
||||
"opacity-100"
|
||||
)}
|
||||
>
|
||||
<div className="relative h-6 w-6">
|
||||
{thread.visibility === "SEARCH_SPACE" ? (
|
||||
<Users
|
||||
aria-label={t("shared_chat") || "Shared chat"}
|
||||
className={cn(
|
||||
"absolute left-1/2 top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 text-muted-foreground/50",
|
||||
!isMobile &&
|
||||
(openDropdownId === thread.id
|
||||
? "opacity-0"
|
||||
: "opacity-100 group-hover/item:opacity-0")
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
<div className="relative flex h-7 w-40 items-center justify-end">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-1 flex items-center justify-end gap-2 transition-opacity",
|
||||
openDropdownId === thread.id
|
||||
? "opacity-0"
|
||||
: "opacity-100 group-hover/item:opacity-0"
|
||||
)}
|
||||
>
|
||||
{thread.visibility === "SEARCH_SPACE" ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-5 shrink-0 rounded-sm border-0 bg-popover-foreground/10 px-1.5 text-[11px] text-popover-foreground hover:bg-popover-foreground/10"
|
||||
>
|
||||
Shared
|
||||
</Badge>
|
||||
) : null}
|
||||
<span className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{formatRelativeDate(thread.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<DropdownMenu
|
||||
open={openDropdownId === thread.id}
|
||||
onOpenChange={(isOpen) => setOpenDropdownId(isOpen ? thread.id : null)}
|
||||
|
|
@ -457,18 +417,16 @@ export function AllChatsSidebarContent({
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"pointer-events-auto h-6 w-6 hover:bg-transparent",
|
||||
"pointer-events-auto absolute right-0 h-7 w-7 hover:bg-transparent",
|
||||
openDropdownId === thread.id && "bg-accent hover:bg-accent",
|
||||
!isMobile &&
|
||||
openDropdownId !== thread.id &&
|
||||
"opacity-0 group-hover/item:opacity-100"
|
||||
openDropdownId !== thread.id && "opacity-0 group-hover/item:opacity-100"
|
||||
)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Spinner size="xs" />
|
||||
) : (
|
||||
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="sr-only">{t("more_options") || "More options"}</span>
|
||||
</Button>
|
||||
|
|
@ -511,30 +469,35 @@ export function AllChatsSidebarContent({
|
|||
</div>
|
||||
);
|
||||
})}
|
||||
{isLoadingMoreThreads ? (
|
||||
<div className="mt-1 space-y-1">
|
||||
{[62, 48, 56].map((titleWidth) => (
|
||||
<div
|
||||
key={`tail-skeleton-${titleWidth}`}
|
||||
className="rounded-md px-3 py-1.5 md:py-2"
|
||||
>
|
||||
<Skeleton
|
||||
className="h-4 rounded md:h-4.5"
|
||||
style={{ width: `${titleWidth}%` }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : isSearchMode ? (
|
||||
<div className="text-center py-8">
|
||||
<Search className="mx-auto mb-2.5 h-10 w-10 text-muted-foreground" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("no_chats_found") || "No chats found"}
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground/70">
|
||||
{t("try_different_search") || "Try a different search term"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<User className="mx-auto mb-2.5 h-10 w-10 text-muted-foreground" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-base font-medium text-muted-foreground">
|
||||
{showArchived
|
||||
? t("no_archived_chats") || "No archived chats"
|
||||
: t("no_chats") || "No chats"}
|
||||
</p>
|
||||
{!showArchived && (
|
||||
<p className="mt-1 text-[11px] text-muted-foreground/70">
|
||||
{t("start_new_chat_hint") || "Start a new chat from the chat page"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -584,25 +547,14 @@ export function AllChatsSidebarContent({
|
|||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AllChatsSidebar({
|
||||
open,
|
||||
onOpenChange,
|
||||
searchSpaceId,
|
||||
onCloseMobileSidebar,
|
||||
}: AllChatsSidebarProps) {
|
||||
const t = useTranslations("sidebar");
|
||||
|
||||
export function AllChatsWorkspaceContent({ workspaceId }: { workspaceId: string }) {
|
||||
return (
|
||||
<SidebarSlideOutPanel open={open} onOpenChange={onOpenChange} ariaLabel={t("chats") || "Chats"}>
|
||||
<AllChatsSidebarContent
|
||||
onOpenChange={onOpenChange}
|
||||
searchSpaceId={searchSpaceId}
|
||||
onCloseMobileSidebar={onCloseMobileSidebar}
|
||||
/>
|
||||
</SidebarSlideOutPanel>
|
||||
<div className="flex h-full min-h-0 w-full overflow-hidden text-sidebar-foreground">
|
||||
<AllChatsContent workspaceId={workspaceId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,11 +14,11 @@ import { useLongPress } from "@/hooks/use-long-press";
|
|||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { useTypewriter } from "@/hooks/use-typewriter";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { sidebarListItemClassName } from "./SidebarListItem";
|
||||
|
||||
interface ChatListItemProps {
|
||||
name: string;
|
||||
isActive?: boolean;
|
||||
isShared?: boolean;
|
||||
archived?: boolean;
|
||||
dropdownOpen?: boolean;
|
||||
onDropdownOpenChange?: (open: boolean) => void;
|
||||
|
|
@ -47,6 +47,7 @@ export function ChatListItem({
|
|||
const dropdownOpen = controlledOpen ?? internalOpen;
|
||||
const setDropdownOpen = onDropdownOpenChange ?? setInternalOpen;
|
||||
const animatedName = useTypewriter(name);
|
||||
const isHighlighted = isActive || dropdownOpen;
|
||||
|
||||
const { handlers: longPressHandlers, wasLongPress } = useLongPress(
|
||||
useCallback(() => setDropdownOpen(true), [setDropdownOpen])
|
||||
|
|
@ -66,12 +67,11 @@ export function ChatListItem({
|
|||
onMouseEnter={onPrefetch}
|
||||
onFocus={onPrefetch}
|
||||
{...(isMobile ? longPressHandlers : {})}
|
||||
className={cn(
|
||||
"h-auto w-full justify-start gap-2 overflow-hidden px-2 py-1.5 text-left font-normal",
|
||||
"group-hover/item:bg-accent group-hover/item:text-accent-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
isActive && "bg-accent text-accent-foreground"
|
||||
)}
|
||||
className={sidebarListItemClassName({
|
||||
active: isHighlighted,
|
||||
className:
|
||||
"justify-start gap-2 overflow-hidden px-2 py-1.5 font-normal group-hover/item:bg-accent group-hover/item:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
})}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{animatedName}</span>
|
||||
</Button>
|
||||
|
|
@ -80,7 +80,7 @@ export function ChatListItem({
|
|||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute right-0 top-0 bottom-0 flex items-center pr-1 pl-6 rounded-r-md",
|
||||
isActive
|
||||
isHighlighted
|
||||
? "bg-gradient-to-l from-accent from-60% to-transparent"
|
||||
: "bg-gradient-to-l from-sidebar from-60% to-transparent group-hover/item:from-accent",
|
||||
isMobile
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ function formatUsd(micros: number): string {
|
|||
* premium-credit meters. Values come from Zero (live-replicated from Postgres)
|
||||
* as integer micro-USD (1_000_000 == $1.00). A low-balance warning highlights
|
||||
* the amount when it falls below $0.50 so the user knows to top up or enable
|
||||
* auto-reload.
|
||||
* automatic top-ups.
|
||||
*/
|
||||
export function CreditBalanceDisplay() {
|
||||
const isAnonymous = useIsAnonymous();
|
||||
|
|
@ -46,7 +46,7 @@ export function CreditBalanceDisplay() {
|
|||
"font-medium tabular-nums",
|
||||
isLow ? "text-amber-600 dark:text-amber-500" : "text-foreground"
|
||||
)}
|
||||
title={isLow ? "Low balance — buy credits or enable auto-reload" : undefined}
|
||||
title={isLow ? "Low balance: add credits or enable top-ups" : undefined}
|
||||
>
|
||||
{formatUsd(balanceMicros)}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ interface DesktopLocalTabContentProps {
|
|||
localRootPaths: string[];
|
||||
canAddMoreLocalRoots: boolean;
|
||||
maxLocalFilesystemRoots: number;
|
||||
searchSpaceId: number;
|
||||
workspaceId: number;
|
||||
onPickFilesystemRoot: () => Promise<void> | void;
|
||||
onRemoveFilesystemRoot: (rootPath: string) => Promise<void> | void;
|
||||
onClearFilesystemRoots: () => Promise<void> | void;
|
||||
|
|
@ -37,7 +37,7 @@ export function DesktopLocalTabContent({
|
|||
localRootPaths,
|
||||
canAddMoreLocalRoots,
|
||||
maxLocalFilesystemRoots,
|
||||
searchSpaceId,
|
||||
workspaceId,
|
||||
onPickFilesystemRoot,
|
||||
onRemoveFilesystemRoot,
|
||||
onClearFilesystemRoots,
|
||||
|
|
@ -49,17 +49,17 @@ export function DesktopLocalTabContent({
|
|||
const localSearchInputRef = useRef<HTMLInputElement>(null);
|
||||
const [expandedFolderKeyMap, setExpandedFolderKeyMap] = useAtom(localExpandedFolderKeysAtom);
|
||||
const expandedFolderKeys = useMemo(
|
||||
() => new Set(expandedFolderKeyMap[searchSpaceId] ?? []),
|
||||
[expandedFolderKeyMap, searchSpaceId]
|
||||
() => new Set(expandedFolderKeyMap[workspaceId] ?? []),
|
||||
[expandedFolderKeyMap, workspaceId]
|
||||
);
|
||||
const handleExpandedFolderKeysChange = useCallback(
|
||||
(nextExpandedKeys: Set<string>) => {
|
||||
setExpandedFolderKeyMap((prev) => ({
|
||||
...prev,
|
||||
[searchSpaceId]: Array.from(nextExpandedKeys),
|
||||
[workspaceId]: Array.from(nextExpandedKeys),
|
||||
}));
|
||||
},
|
||||
[searchSpaceId, setExpandedFolderKeyMap]
|
||||
[workspaceId, setExpandedFolderKeyMap]
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -199,7 +199,7 @@ export function DesktopLocalTabContent({
|
|||
</div>
|
||||
<LocalFilesystemBrowser
|
||||
rootPaths={localRootPaths}
|
||||
searchSpaceId={searchSpaceId}
|
||||
workspaceId={workspaceId}
|
||||
active
|
||||
searchQuery={debouncedLocalSearch.trim() || undefined}
|
||||
onOpenFile={onOpenLocalFile}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue