From fc3fba73804624c840e2ebafc13ab846785c997f Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Sat, 1 Aug 2026 20:42:14 +0530 Subject: [PATCH] Improve Nova knowledge-base connection flow --- apps/web/components/chat/index.tsx | 11 + .../components/chat/message/agent-message.tsx | 360 +++++++++++++++--- .../web/lib/chat-knowledge-connectors.test.ts | 13 + apps/web/lib/chat-knowledge-connectors.ts | 127 ++++++ 4 files changed, 462 insertions(+), 49 deletions(-) diff --git a/apps/web/components/chat/index.tsx b/apps/web/components/chat/index.tsx index b2329751..094b0961 100644 --- a/apps/web/components/chat/index.tsx +++ b/apps/web/components/chat/index.tsx @@ -89,6 +89,10 @@ import { type NovaResearchRun, researchPollDelayMs, } from "@/lib/nova-research" +import { + releaseNovaKnowledgeConnectionWindow, + reserveNovaKnowledgeConnectionWindowForMessage, +} from "@/lib/chat-knowledge-connectors" type ChatMessageSendSource = "typed" | "suggested" | "highlight" | "home" @@ -956,6 +960,10 @@ export function ChatSidebar({ if (hasBusy) return false const hasErrored = drafts.some((d) => d.status === "error") if (hasErrored) return false + const reservedKnowledgeProvider = + status !== "submitted" && status !== "streaming" + ? reserveNovaKnowledgeConnectionWindowForMessage(trimmed) + : null const chatIdForSend = threadId ?? fallbackChatId @@ -1107,6 +1115,9 @@ export function ChatSidebar({ return true } catch (error) { + if (reservedKnowledgeProvider) { + releaseNovaKnowledgeConnectionWindow(reservedKnowledgeProvider) + } pendingRequestAttachmentsRef.current = [] toast.error("Failed to send message", { description: diff --git a/apps/web/components/chat/message/agent-message.tsx b/apps/web/components/chat/message/agent-message.tsx index 6edb8f8c..ba437f6e 100644 --- a/apps/web/components/chat/message/agent-message.tsx +++ b/apps/web/components/chat/message/agent-message.tsx @@ -1,6 +1,13 @@ "use client" -import { type ReactNode, useEffect, useMemo, useRef, useState } from "react" +import { + type ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react" import type { UIMessage } from "@ai-sdk/react" import { useQuery } from "@tanstack/react-query" import { Streamdown } from "streamdown" @@ -25,11 +32,16 @@ import { } from "lucide-react" import { $fetch } from "@lib/api" import { cn } from "@lib/utils" +import { GoogleDrive, Granola, Notion, OneDrive } from "@ui/assets/icons" import { GranolaConnectModal } from "@/components/granola-connect-modal" import { isWebSearchToolName } from "@/lib/chat-web-search-tools" import { deriveKnowledgeConnectorState, + isNovaKnowledgeConnectionWindowOpen, isNovaKnowledgeBaseProvider, + navigateReservedNovaKnowledgeConnectionWindow, + releaseNovaKnowledgeConnectionWindow, + reserveNovaKnowledgeConnectionWindow, type KnowledgeConnection, type NovaKnowledgeBaseProvider, type NovaKnowledgeBaseStatus, @@ -158,6 +170,7 @@ type NovaConnectorToolOutput = { success?: boolean error?: string kind?: string + requestedAction?: "connect" connectors?: NovaConnectorCardData[] connector?: NovaConnectorCardData keyReveal?: { pluginId: string; label?: string } | null @@ -174,6 +187,7 @@ const NOVA_CONNECTOR_TOOLS = new Set([ "prepareNovaPluginSetup", "listNovaKnowledgeBases", "getNovaKnowledgeBase", + "startNovaKnowledgeBaseConnection", ]) const CONNECTOR_ICON_FALLBACKS: Record = { @@ -182,6 +196,25 @@ const CONNECTOR_ICON_FALLBACKS: Record = { mcp_cursor: "/mcp-supported-tools/cursor.png", } +const KNOWLEDGE_CONNECTION_TIMEOUT_MS = 2 * 60 * 1000 + +function KnowledgeBaseProviderIcon({ + provider, +}: { + provider: NovaKnowledgeBaseProvider +}) { + if (provider === "google-drive") { + return + } + if (provider === "notion") { + return + } + if (provider === "onedrive") { + return + } + return +} + const STATUS_COPY: Record< NovaConnectorStatus, { label: string; className: string } @@ -326,7 +359,8 @@ function connectorToolPriority(toolName: string | null): number { if (toolName === "prepareNovaPluginSetup") return 2 if ( toolName === "getNovaConnectorSetup" || - toolName === "getNovaKnowledgeBase" + toolName === "getNovaKnowledgeBase" || + toolName === "startNovaKnowledgeBaseConnection" ) return 1 return 0 @@ -494,17 +528,29 @@ function knowledgeFallbackStatus( function KnowledgeBaseConnectAction({ connector, onConnected, + onPendingChange, + autoStart = false, + attemptKey, }: { connector: NovaConnectorCardData onConnected: () => void + onPendingChange?: (pending: boolean) => void + autoStart?: boolean + attemptKey?: string }) { const [granolaOpen, setGranolaOpen] = useState(false) - const [connecting, setConnecting] = useState(false) + const [connectionState, setConnectionState] = useState< + "idle" | "starting" | "waiting" | "error" + >("idle") const [error, setError] = useState(null) + const startedRef = useRef(false) + const granolaSucceededRef = useRef(false) + const startingConnectionCountRef = useRef(connector.connectionCount ?? 0) const provider = connector.provider - if (!provider || connector.kind !== "knowledge") return null - - const disabled = connector.canConnect === false || connecting + const disabled = + connector.canConnect === false || + connectionState === "starting" || + connectionState === "waiting" const buttonLabel = connector.status === "active" || connector.status === "syncing" ? "Add another" @@ -512,76 +558,222 @@ function KnowledgeBaseConnectAction({ ? "Reconnect" : "Connect" - const connect = async () => { - setError(null) - if (provider === "granola") { - setGranolaOpen(true) + const clearPendingAttempt = useCallback(() => { + if (attemptKey) sessionStorage.removeItem(attemptKey) + }, [attemptKey]) + const stopWaiting = useCallback( + (message: string) => { + if (provider) releaseNovaKnowledgeConnectionWindow(provider) + clearPendingAttempt() + onPendingChange?.(false) + setError(message) + setConnectionState("error") + }, + [clearPendingAttempt, onPendingChange, provider], + ) + + const connect = useCallback( + async (reserveTab = false) => { + if (!provider || connector.kind !== "knowledge") return + startingConnectionCountRef.current = connector.connectionCount ?? 0 + setError(null) + setConnectionState("starting") + onPendingChange?.(true) + if (provider === "granola") { + granolaSucceededRef.current = false + setGranolaOpen(true) + setConnectionState("waiting") + return + } + if (reserveTab) reserveNovaKnowledgeConnectionWindow(provider) + + try { + const response = await $fetch("@post/connections/:provider", { + params: { provider }, + body: { + redirectUrl: window.location.href, + containerTags: [], + }, + }) + if (response.error) { + throw new Error( + response.error.message || "Failed to start connection", + ) + } + const data = response.data as { authLink?: string } | undefined + const authLink = safeExternalUrl(data?.authLink) + if (!authLink) throw new Error("Connection link was not returned") + if (attemptKey) sessionStorage.setItem(attemptKey, String(Date.now())) + if ( + !navigateReservedNovaKnowledgeConnectionWindow(provider, authLink) + ) { + clearPendingAttempt() + throw new Error( + "Your browser blocked the connection tab. Allow pop-ups for Supermemory and try again.", + ) + } + setConnectionState("waiting") + } catch (cause) { + stopWaiting( + cause instanceof Error ? cause.message : "Failed to connect", + ) + } + }, + [ + attemptKey, + clearPendingAttempt, + connector.connectionCount, + connector.kind, + onPendingChange, + provider, + stopWaiting, + ], + ) + + useEffect(() => { + if ( + !autoStart || + !provider || + connector.kind !== "knowledge" || + connector.canConnect === false || + startedRef.current + ) { + return + } + startedRef.current = true + if (attemptKey && sessionStorage.getItem(attemptKey)) { + setConnectionState("waiting") + onPendingChange?.(true) + return + } + void connect(false) + }, [ + attemptKey, + autoStart, + connect, + connector.canConnect, + connector.kind, + onPendingChange, + provider, + ]) + + useEffect(() => { + if (connectionState !== "waiting" || !provider || provider === "granola") { return } - setConnecting(true) - try { - const response = await $fetch("@post/connections/:provider", { - params: { provider }, - body: { - redirectUrl: window.location.href, - containerTags: [], - }, - }) - if (response.error) { - throw new Error(response.error.message || "Failed to start connection") + const closedCheck = window.setInterval(() => { + if (!isNovaKnowledgeConnectionWindowOpen(provider)) { + stopWaiting("Connection cancelled. You can try again.") } - const data = response.data as { authLink?: string } | undefined - const authLink = safeExternalUrl(data?.authLink) - if (!authLink) throw new Error("Connection link was not returned") - window.location.assign(authLink) - } catch (cause) { - setError(cause instanceof Error ? cause.message : "Failed to connect") - setConnecting(false) + }, 500) + const timeout = window.setTimeout(() => { + stopWaiting("Connection timed out. You can try again.") + }, KNOWLEDGE_CONNECTION_TIMEOUT_MS) + + return () => { + window.clearInterval(closedCheck) + window.clearTimeout(timeout) } - } + }, [connectionState, provider, stopWaiting]) + + useEffect(() => { + const connectionCompleted = + (connector.connectionCount ?? 0) > startingConnectionCountRef.current || + (startingConnectionCountRef.current === 0 && + (connector.status === "active" || connector.status === "syncing")) + if (connectionState !== "waiting" || !connectionCompleted) return + clearPendingAttempt() + if (provider) releaseNovaKnowledgeConnectionWindow(provider) + setConnectionState("idle") + onPendingChange?.(false) + onConnected() + }, [ + clearPendingAttempt, + connectionState, + connector.connectionCount, + connector.status, + onConnected, + onPendingChange, + provider, + ]) + + if (!provider || connector.kind !== "knowledge") return null return ( - <> - +
+ {connectionState === "starting" || connectionState === "waiting" ? ( +
+
+ + {connectionState === "starting" ? "Opening…" : "Waiting…"} +
+ {connectionState === "waiting" ? ( + + ) : null} +
+ ) : ( + + )} {connector.canConnect === false && connector.disabledReason ? (

{connector.disabledReason}

) : null} {error ? ( -

{error}

+

{error}

) : null} {provider === "granola" ? ( { + setGranolaOpen(open) + if (!open && !granolaSucceededRef.current) { + stopWaiting("Connection cancelled. You can try again.") + } + }} containerTags={[]} - onSuccess={onConnected} + onSuccess={() => { + granolaSucceededRef.current = true + clearPendingAttempt() + setError(null) + setConnectionState("idle") + onPendingChange?.(false) + onConnected() + }} /> ) : null} - +
) } function NovaConnectorCard({ connector, onConnectionsChanged, + onConnectionPendingChange, + autoStartConnection = false, + connectionAttemptKey, }: { connector: NovaConnectorCardData onConnectionsChanged?: () => void + onConnectionPendingChange?: (pending: boolean) => void + autoStartConnection?: boolean + connectionAttemptKey?: string }) { const [revealedKey, setRevealedKey] = useState() const displayedConnector = connector @@ -590,6 +782,52 @@ function NovaConnectorCard({ ) const isUpgrade = displayedConnector.status === "upgrade_required" const iconSrc = connectorIconSrc(displayedConnector) + if ( + displayedConnector.kind === "knowledge" && + isNovaKnowledgeBaseProvider(displayedConnector.provider) + ) { + const connectionCount = displayedConnector.connectionCount ?? 0 + const indexedItems = displayedConnector.documentCount ?? 0 + const statusText = + displayedConnector.status === "syncing" + ? "Syncing memories…" + : displayedConnector.status === "error" + ? (displayedConnector.syncError ?? "Connection needs attention") + : connectionCount > 0 + ? `${connectionCount} connection${connectionCount === 1 ? "" : "s"} · ${indexedItems} indexed items` + : displayedConnector.description + + return ( +
+
+ +
+

+ {displayedConnector.name ?? "Knowledge base"} +

+ {statusText ? ( +

+ {statusText} +

+ ) : null} +
+
+ {isUpgrade ? ( + + Pro + + ) : ( + onConnectionsChanged?.()} + onPendingChange={onConnectionPendingChange} + autoStart={autoStartConnection} + attemptKey={connectionAttemptKey} + /> + )} +
+ ) + } return (
@@ -696,6 +934,9 @@ function NovaConnectorCard({ onConnectionsChanged?.()} + onPendingChange={onConnectionPendingChange} + autoStart={autoStartConnection} + attemptKey={connectionAttemptKey} /> ) : null} {displayedConnector.docsUrl ? ( @@ -778,8 +1019,15 @@ function NovaConnectorToolDisplay({ part }: { part: ToolCallDisplayPart }) { const [expandedConnectorKey, setExpandedConnectorKey] = useState< string | null >(null) + const [connectionPending, setConnectionPending] = useState(false) const toolName = connectorToolName(part) const output = unwrapToolOutput(part.output) + const autoStartConnection = + toolName === "startNovaKnowledgeBaseConnection" && + output?.requestedAction === "connect" + const connectionAttemptKey = part.toolCallId + ? `nova-knowledge-connect:${part.toolCallId}` + : undefined const connectors = output?.connector ? [output.connector] : (output?.connectors ?? []) @@ -802,6 +1050,7 @@ function NovaConnectorToolDisplay({ part }: { part: ToolCallDisplayPart }) { enabled: hasKnowledgeBases && output?.success !== false, staleTime: 30 * 1000, refetchInterval: (query) => { + if (connectionPending) return 3000 const connections = query.state.data as KnowledgeConnection[] | undefined return connections?.some( (connection) => connection.lastSyncRun?.status === "running", @@ -809,6 +1058,7 @@ function NovaConnectorToolDisplay({ part }: { part: ToolCallDisplayPart }) { ? 5000 : false }, + refetchIntervalInBackground: connectionPending, }) const displayedConnectors = connectors.map((connector) => { if ( @@ -836,7 +1086,8 @@ function NovaConnectorToolDisplay({ part }: { part: ToolCallDisplayPart }) { {toolName === "listNovaKnowledgeBases" || - toolName === "getNovaKnowledgeBase" + toolName === "getNovaKnowledgeBase" || + toolName === "startNovaKnowledgeBaseConnection" ? "Checking knowledge bases…" : "Checking Supermemory setup…"} @@ -908,6 +1159,9 @@ function NovaConnectorToolDisplay({ part }: { part: ToolCallDisplayPart }) { key={connectorCardKey(connector)} connector={connector} onConnectionsChanged={() => void refetchConnections()} + onConnectionPendingChange={setConnectionPending} + autoStartConnection={autoStartConnection} + connectionAttemptKey={connectionAttemptKey} /> ), )} @@ -917,6 +1171,9 @@ function NovaConnectorToolDisplay({ part }: { part: ToolCallDisplayPart }) { void refetchConnections()} + onConnectionPendingChange={setConnectionPending} + autoStartConnection={autoStartConnection} + connectionAttemptKey={connectionAttemptKey} />
) : null} @@ -1754,6 +2011,10 @@ export function AgentMessage({ const hasAssistantText = message.parts.some( (p) => p.type === "text" && (p as { text?: string }).text?.trim(), ) + const hasAutoStartKnowledgeConnection = message.parts.some( + (part) => + connectorToolNameFromPart(part) === "startNovaKnowledgeBaseConnection", + ) const markdownComponents = useMemo( () => makeMarkdownComponents(webSources, citationIndex, documentByKnownId), [webSources, citationIndex, documentByKnownId], @@ -1813,6 +2074,7 @@ export function AgentMessage({ ) } if (part.type === "text") { + if (hasAutoStartKnowledgeConnection) return null // Skip fragments mid-run — source-url citations split one answer into // many text parts; rendering each separately tears markdown (lists etc.). let prev = partIndex - 1 @@ -1893,7 +2155,7 @@ export function AgentMessage({ })}
- {hasAssistantText && ( + {hasAssistantText && !hasAutoStartKnowledgeConnection && (
{ expect(isNovaKnowledgeBaseProvider("granola")).toBe(true) expect(isNovaKnowledgeBaseProvider("dropbox")).toBe(false) }) + + it("detects explicit connection requests without treating status questions as actions", () => { + expect( + detectNovaKnowledgeBaseConnectIntent("Connect my Notion workspace"), + ).toBe("notion") + expect( + detectNovaKnowledgeBaseConnectIntent("Please link Google Drive"), + ).toBe("google-drive") + expect( + detectNovaKnowledgeBaseConnectIntent("Is Notion connected?"), + ).toBeNull() + }) }) diff --git a/apps/web/lib/chat-knowledge-connectors.ts b/apps/web/lib/chat-knowledge-connectors.ts index 1d504d08..92727257 100644 --- a/apps/web/lib/chat-knowledge-connectors.ts +++ b/apps/web/lib/chat-knowledge-connectors.ts @@ -33,6 +33,133 @@ export type KnowledgeConnectorState = { lastSyncAt?: string } +type PendingKnowledgeConnectionWindow = { + popup: Window + cleanupTimer: number +} + +const CONNECT_INTENT_RE = /\b(connect|link|authorize|add|set\s*up|setup)\b/i +const pendingConnectionWindows = new Map< + NovaKnowledgeBaseProvider, + PendingKnowledgeConnectionWindow +>() + +const KNOWLEDGE_BASE_ALIASES: Record< + NovaKnowledgeBaseProvider, + readonly string[] +> = { + "google-drive": ["google drive", "gdrive"], + notion: ["notion"], + onedrive: ["onedrive", "one drive"], + granola: ["granola", "granola notes"], +} + +const KNOWLEDGE_BASE_NAMES: Record = { + "google-drive": "Google Drive", + notion: "Notion", + onedrive: "OneDrive", + granola: "Granola", +} + +function normalizeConnectIntent(value: string): string { + return value.trim().toLowerCase().replace(/[_-]+/g, " ").replace(/\s+/g, " ") +} + +export function detectNovaKnowledgeBaseConnectIntent( + message: string, +): NovaKnowledgeBaseProvider | null { + if (!CONNECT_INTENT_RE.test(message)) return null + const normalized = normalizeConnectIntent(message) + return ( + NOVA_KNOWLEDGE_BASE_PROVIDERS.find((provider) => + KNOWLEDGE_BASE_ALIASES[provider].some((alias) => + normalized.includes(alias), + ), + ) ?? null + ) +} + +function closePendingConnectionWindow( + provider: NovaKnowledgeBaseProvider, + closePopup: boolean, +) { + const pending = pendingConnectionWindows.get(provider) + if (!pending) return + window.clearTimeout(pending.cleanupTimer) + pendingConnectionWindows.delete(provider) + if (closePopup && !pending.popup.closed) pending.popup.close() +} + +export function reserveNovaKnowledgeConnectionWindow( + provider: NovaKnowledgeBaseProvider, +): boolean { + if (provider === "granola" || typeof window === "undefined") return false + const existing = pendingConnectionWindows.get(provider) + if (existing && !existing.popup.closed) { + existing.popup.focus() + return true + } + if (existing) closePendingConnectionWindow(provider, false) + + const popup = window.open( + "about:blank", + `supermemory-nova-${provider}-connection`, + ) + if (!popup) return false + popup.opener = null + popup.document.title = `Connecting ${KNOWLEDGE_BASE_NAMES[provider]}…` + popup.document.body.style.cssText = + "margin:0;min-height:100vh;display:grid;place-items:center;background:#080b0f;color:#fafafa;font-family:ui-sans-serif,system-ui,sans-serif" + const status = popup.document.createElement("p") + status.textContent = `Preparing ${KNOWLEDGE_BASE_NAMES[provider]} connection…` + status.style.cssText = "font-size:16px;opacity:.75" + popup.document.body.append(status) + + const cleanupTimer = window.setTimeout( + () => closePendingConnectionWindow(provider, true), + 5 * 60 * 1000, + ) + pendingConnectionWindows.set(provider, { popup, cleanupTimer }) + return true +} + +export function reserveNovaKnowledgeConnectionWindowForMessage( + message: string, +): NovaKnowledgeBaseProvider | null { + const provider = detectNovaKnowledgeBaseConnectIntent(message) + if (!provider || provider === "granola") return null + reserveNovaKnowledgeConnectionWindow(provider) + return provider +} + +export function navigateReservedNovaKnowledgeConnectionWindow( + provider: NovaKnowledgeBaseProvider, + authLink: string, +): boolean { + const pending = pendingConnectionWindows.get(provider) + if (!pending || pending.popup.closed) { + if (pending) closePendingConnectionWindow(provider, false) + return false + } + pending.popup.location.replace(authLink) + pending.popup.focus() + return true +} + +export function isNovaKnowledgeConnectionWindowOpen( + provider: NovaKnowledgeBaseProvider, +): boolean { + const pending = pendingConnectionWindows.get(provider) + return Boolean(pending && !pending.popup.closed) +} + +export function releaseNovaKnowledgeConnectionWindow( + provider: NovaKnowledgeBaseProvider, +) { + if (typeof window === "undefined") return + closePendingConnectionWindow(provider, true) +} + export function isNovaKnowledgeBaseProvider( value: string | undefined, ): value is NovaKnowledgeBaseProvider {