From 46b318dc396a7bf9c20c32336508e1a5808af41d Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Fri, 31 Jul 2026 20:10:59 +0530 Subject: [PATCH 1/4] connect knowledge bases from Nova chat --- .../components/chat/message/agent-message.tsx | 279 ++++++++++++++++-- .../web/lib/chat-knowledge-connectors.test.ts | 47 +++ apps/web/lib/chat-knowledge-connectors.ts | 94 ++++++ 3 files changed, 391 insertions(+), 29 deletions(-) create mode 100644 apps/web/lib/chat-knowledge-connectors.test.ts create mode 100644 apps/web/lib/chat-knowledge-connectors.ts diff --git a/apps/web/components/chat/message/agent-message.tsx b/apps/web/components/chat/message/agent-message.tsx index 4b8581fe..6edb8f8c 100644 --- a/apps/web/components/chat/message/agent-message.tsx +++ b/apps/web/components/chat/message/agent-message.tsx @@ -23,8 +23,17 @@ import { XCircleIcon, ZapIcon, } from "lucide-react" +import { $fetch } from "@lib/api" import { cn } from "@lib/utils" +import { GranolaConnectModal } from "@/components/granola-connect-modal" import { isWebSearchToolName } from "@/lib/chat-web-search-tools" +import { + deriveKnowledgeConnectorState, + isNovaKnowledgeBaseProvider, + type KnowledgeConnection, + type NovaKnowledgeBaseProvider, + type NovaKnowledgeBaseStatus, +} from "@/lib/chat-knowledge-connectors" import { buildCitationIndex, fetchDocumentsByIds, @@ -105,6 +114,8 @@ function faviconUrl(host: string): string { type NovaConnectorStatus = | "active" + | "syncing" + | "error" | "setup_pending" | "not_connected" | "upgrade_required" @@ -119,8 +130,9 @@ type NovaConnectorStep = { } type NovaConnectorCardData = { - kind?: "plugin" | "mcp" + kind?: "plugin" | "mcp" | "knowledge" id?: string + provider?: NovaKnowledgeBaseProvider name?: string icon?: string description?: string @@ -132,6 +144,14 @@ type NovaConnectorCardData = { requiresPro?: boolean canGenerateKey?: boolean keyPluginId?: string + connectMode?: "oauth" | "api_key" + canConnect?: boolean + disabledReason?: string + connectionCount?: number + documentCount?: number + lastSyncStatus?: "running" | "completed" | "failed" + lastSyncAt?: string + syncError?: string } type NovaConnectorToolOutput = { @@ -141,13 +161,19 @@ type NovaConnectorToolOutput = { connectors?: NovaConnectorCardData[] connector?: NovaConnectorCardData keyReveal?: { pluginId: string; label?: string } | null - available?: Array<{ kind: "plugin" | "mcp"; id: string; name: string }> + available?: Array<{ + kind: "plugin" | "mcp" | "knowledge" + id: string + name: string + }> } const NOVA_CONNECTOR_TOOLS = new Set([ "listNovaConnectors", "getNovaConnectorSetup", "prepareNovaPluginSetup", + "listNovaKnowledgeBases", + "getNovaKnowledgeBase", ]) const CONNECTOR_ICON_FALLBACKS: Record = { @@ -164,6 +190,14 @@ const STATUS_COPY: Record< label: "Active", className: "border-emerald-400/20 bg-emerald-400/10 text-emerald-300", }, + syncing: { + label: "Syncing", + className: "border-blue-400/20 bg-blue-400/10 text-blue-300", + }, + error: { + label: "Needs attention", + className: "border-red-400/20 bg-red-400/10 text-red-300", + }, setup_pending: { label: "Finish setup", className: "border-amber-400/20 bg-amber-400/10 text-amber-300", @@ -290,7 +324,11 @@ function connectorOutputFromPart( function connectorToolPriority(toolName: string | null): number { if (toolName === "prepareNovaPluginSetup") return 2 - if (toolName === "getNovaConnectorSetup") return 1 + if ( + toolName === "getNovaConnectorSetup" || + toolName === "getNovaKnowledgeBase" + ) + return 1 return 0 } @@ -439,15 +477,119 @@ function RevealPluginKeyButton({ ) } -function NovaConnectorCard({ +function knowledgeFallbackStatus( + status: NovaConnectorStatus | undefined, +): NovaKnowledgeBaseStatus { + if ( + status === "active" || + status === "syncing" || + status === "error" || + status === "upgrade_required" + ) { + return status + } + return "not_connected" +} + +function KnowledgeBaseConnectAction({ connector, + onConnected, }: { connector: NovaConnectorCardData + onConnected: () => void +}) { + const [granolaOpen, setGranolaOpen] = useState(false) + const [connecting, setConnecting] = useState(false) + const [error, setError] = useState(null) + const provider = connector.provider + if (!provider || connector.kind !== "knowledge") return null + + const disabled = connector.canConnect === false || connecting + const buttonLabel = + connector.status === "active" || connector.status === "syncing" + ? "Add another" + : connector.status === "error" + ? "Reconnect" + : "Connect" + + const connect = async () => { + setError(null) + if (provider === "granola") { + setGranolaOpen(true) + 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 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) + } + } + + return ( + <> + + {connector.canConnect === false && connector.disabledReason ? ( +

+ {connector.disabledReason} +

+ ) : null} + {error ? ( +

{error}

+ ) : null} + {provider === "granola" ? ( + + ) : null} + + ) +} + +function NovaConnectorCard({ + connector, + onConnectionsChanged, +}: { + connector: NovaConnectorCardData + onConnectionsChanged?: () => void }) { const [revealedKey, setRevealedKey] = useState() - const needsKey = Boolean(connector.canGenerateKey && connector.keyPluginId) - const isUpgrade = connector.status === "upgrade_required" - const iconSrc = connectorIconSrc(connector) + const displayedConnector = connector + const needsKey = Boolean( + displayedConnector.canGenerateKey && displayedConnector.keyPluginId, + ) + const isUpgrade = displayedConnector.status === "upgrade_required" + const iconSrc = connectorIconSrc(displayedConnector) return (
@@ -459,8 +601,8 @@ function NovaConnectorCard({ className="size-6 rounded object-contain" onError={(event) => { const img = event.currentTarget - const fallback = connector.id - ? CONNECTOR_ICON_FALLBACKS[connector.id] + const fallback = displayedConnector.id + ? CONNECTOR_ICON_FALLBACKS[displayedConnector.id] : undefined if (fallback && img.dataset.fallbackApplied !== "true") { img.dataset.fallbackApplied = "true" @@ -477,21 +619,33 @@ function NovaConnectorCard({

- {connector.name ?? connector.id ?? "Connector"} + {displayedConnector.name ?? displayedConnector.id ?? "Connector"}

- +
- {connector.description ? ( + {displayedConnector.description ? (

- {connector.description} + {displayedConnector.description} +

+ ) : null} + {displayedConnector.kind === "knowledge" ? ( +

+ {displayedConnector.connectionCount ?? 0} connection + {displayedConnector.connectionCount === 1 ? "" : "s"} ·{" "} + {displayedConnector.documentCount ?? 0} indexed items +

+ ) : null} + {displayedConnector.syncError ? ( +

+ {displayedConnector.syncError}

) : null}
- {connector.installSteps?.length ? ( + {displayedConnector.installSteps?.length ? (
    - {connector.installSteps.map((step, index) => ( + {displayedConnector.installSteps.map((step, index) => (
  1. {index + 1} @@ -526,9 +680,9 @@ function NovaConnectorCard({ ) : null}
    - {needsKey && connector.keyPluginId && !isUpgrade ? ( + {needsKey && displayedConnector.keyPluginId && !isUpgrade ? ( ) : null} @@ -538,9 +692,15 @@ function NovaConnectorCard({ Upgrade to connect ) : null} - {connector.docsUrl ? ( + {displayedConnector.kind === "knowledge" && !isUpgrade ? ( + onConnectionsChanged?.()} + /> + ) : null} + {displayedConnector.docsUrl ? (

    - {connector.kind === "mcp" ? "MCP" : "Plugin"} + {connector.kind === "mcp" + ? "MCP" + : connector.kind === "knowledge" + ? "Knowledge base" + : "Plugin"}

    @@ -616,6 +780,53 @@ function NovaConnectorToolDisplay({ part }: { part: ToolCallDisplayPart }) { >(null) const toolName = connectorToolName(part) const output = unwrapToolOutput(part.output) + const connectors = output?.connector + ? [output.connector] + : (output?.connectors ?? []) + const hasKnowledgeBases = connectors.some( + (connector) => + connector.kind === "knowledge" && + isNovaKnowledgeBaseProvider(connector.provider), + ) + const { data: liveConnections, refetch: refetchConnections } = useQuery({ + queryKey: ["connections"], + queryFn: async () => { + const response = await $fetch("@post/connections/list", { + body: { containerTags: [] }, + }) + if (response.error) { + throw new Error(response.error.message || "Failed to load connections") + } + return response.data as KnowledgeConnection[] + }, + enabled: hasKnowledgeBases && output?.success !== false, + staleTime: 30 * 1000, + refetchInterval: (query) => { + const connections = query.state.data as KnowledgeConnection[] | undefined + return connections?.some( + (connection) => connection.lastSyncRun?.status === "running", + ) + ? 5000 + : false + }, + }) + const displayedConnectors = connectors.map((connector) => { + if ( + !liveConnections || + connector.kind !== "knowledge" || + !isNovaKnowledgeBaseProvider(connector.provider) + ) { + return connector + } + return { + ...connector, + ...deriveKnowledgeConnectorState({ + provider: connector.provider, + connections: liveConnections, + fallbackStatus: knowledgeFallbackStatus(connector.status), + }), + } + }) const isLoading = part.state === "input-streaming" || part.state === "input-available" const isError = part.state === "error" || part.state === "output-error" @@ -623,7 +834,12 @@ function NovaConnectorToolDisplay({ part }: { part: ToolCallDisplayPart }) { return (
    - Checking Supermemory setup… + + {toolName === "listNovaKnowledgeBases" || + toolName === "getNovaKnowledgeBase" + ? "Checking knowledge bases…" + : "Checking Supermemory setup…"} +
    ) } @@ -649,14 +865,13 @@ function NovaConnectorToolDisplay({ part }: { part: ToolCallDisplayPart }) {
) } - const connectors = output.connector - ? [output.connector] - : (output.connectors ?? []) const isConnectorList = - toolName === "listNovaConnectors" && connectors.length > 1 + (toolName === "listNovaConnectors" || + toolName === "listNovaKnowledgeBases") && + displayedConnectors.length > 1 const expandedConnector = isConnectorList && expandedConnectorKey - ? connectors.find( + ? displayedConnectors.find( (connector) => connectorCardKey(connector) === expandedConnectorKey, ) : null @@ -664,7 +879,9 @@ function NovaConnectorToolDisplay({ part }: { part: ToolCallDisplayPart }) {
{isConnectorList ? (

- Supermemory setup options + {toolName === "listNovaKnowledgeBases" + ? "Knowledge bases" + : "Supermemory setup options"}

) : null}
- {connectors.map((connector) => + {displayedConnectors.map((connector) => isConnectorList ? ( void refetchConnections()} /> ), )}
{expandedConnector ? (
- + void refetchConnections()} + />
) : null}
diff --git a/apps/web/lib/chat-knowledge-connectors.test.ts b/apps/web/lib/chat-knowledge-connectors.test.ts new file mode 100644 index 00000000..5aa5e56c --- /dev/null +++ b/apps/web/lib/chat-knowledge-connectors.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "bun:test" +import { + deriveKnowledgeConnectorState, + isNovaKnowledgeBaseProvider, +} from "./chat-knowledge-connectors" + +describe("Nova knowledge connector state", () => { + it("reports live document and connection counts", () => { + expect( + deriveKnowledgeConnectorState({ + provider: "notion", + fallbackStatus: "not_connected", + connections: [ + { + provider: "notion", + metadata: { documentCount: 8 }, + lastSyncRun: { + status: "completed", + completedAt: "2026-07-31T12:00:00.000Z", + }, + }, + ], + }), + ).toEqual({ + status: "active", + connectionCount: 1, + documentCount: 8, + lastSyncStatus: "completed", + lastSyncAt: "2026-07-31T12:00:00.000Z", + }) + }) + + it("preserves upgrade restrictions while refreshing connection state", () => { + expect( + deriveKnowledgeConnectorState({ + provider: "onedrive", + fallbackStatus: "upgrade_required", + connections: [{ provider: "onedrive" }], + }).status, + ).toBe("upgrade_required") + }) + + it("recognizes only supported knowledge-base providers", () => { + expect(isNovaKnowledgeBaseProvider("granola")).toBe(true) + expect(isNovaKnowledgeBaseProvider("dropbox")).toBe(false) + }) +}) diff --git a/apps/web/lib/chat-knowledge-connectors.ts b/apps/web/lib/chat-knowledge-connectors.ts new file mode 100644 index 00000000..1d504d08 --- /dev/null +++ b/apps/web/lib/chat-knowledge-connectors.ts @@ -0,0 +1,94 @@ +export const NOVA_KNOWLEDGE_BASE_PROVIDERS = [ + "google-drive", + "notion", + "onedrive", + "granola", +] as const + +export type NovaKnowledgeBaseProvider = + (typeof NOVA_KNOWLEDGE_BASE_PROVIDERS)[number] + +export type NovaKnowledgeBaseStatus = + | "active" + | "syncing" + | "error" + | "not_connected" + | "upgrade_required" + +export type KnowledgeConnection = { + provider: string + metadata?: { documentCount?: number } | null + lastSyncRun?: { + status?: "running" | "completed" | "failed" + startedAt?: string + completedAt?: string + } | null +} + +export type KnowledgeConnectorState = { + status: NovaKnowledgeBaseStatus + connectionCount: number + documentCount: number + lastSyncStatus?: "running" | "completed" | "failed" + lastSyncAt?: string +} + +export function isNovaKnowledgeBaseProvider( + value: string | undefined, +): value is NovaKnowledgeBaseProvider { + return NOVA_KNOWLEDGE_BASE_PROVIDERS.some((provider) => provider === value) +} + +function latestConnection(connections: KnowledgeConnection[]) { + return connections.reduce((latest, item) => { + if (!latest) return item + const latestTime = Date.parse( + latest.lastSyncRun?.startedAt ?? latest.lastSyncRun?.completedAt ?? "", + ) + const itemTime = Date.parse( + item.lastSyncRun?.startedAt ?? item.lastSyncRun?.completedAt ?? "", + ) + return (Number.isFinite(itemTime) ? itemTime : 0) > + (Number.isFinite(latestTime) ? latestTime : 0) + ? item + : latest + }, undefined) +} + +export function deriveKnowledgeConnectorState({ + provider, + connections, + fallbackStatus, +}: { + provider: NovaKnowledgeBaseProvider + connections: KnowledgeConnection[] + fallbackStatus: NovaKnowledgeBaseStatus +}): KnowledgeConnectorState { + const providerConnections = connections.filter( + (connection) => connection.provider === provider, + ) + const latest = latestConnection(providerConnections) + const lastSyncStatus = latest?.lastSyncRun?.status + const status: NovaKnowledgeBaseStatus = + fallbackStatus === "upgrade_required" + ? "upgrade_required" + : lastSyncStatus === "running" + ? "syncing" + : lastSyncStatus === "failed" + ? "error" + : providerConnections.length > 0 + ? "active" + : "not_connected" + + return { + status, + connectionCount: providerConnections.length, + documentCount: providerConnections.reduce( + (total, connection) => total + (connection.metadata?.documentCount ?? 0), + 0, + ), + lastSyncStatus, + lastSyncAt: + latest?.lastSyncRun?.completedAt ?? latest?.lastSyncRun?.startedAt, + } +} From 751bdab6d1a2313765787cc23a919c7432416112 Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Sat, 1 Aug 2026 20:42:14 +0530 Subject: [PATCH 2/4] 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 { From 402ff4dba97173963150bd6887ba9140dfd5bc60 Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Sat, 1 Aug 2026 20:48:06 +0530 Subject: [PATCH 3/4] Polish Nova knowledge-base cards --- .../components/chat/message/agent-message.tsx | 65 +++++++++++-------- 1 file changed, 37 insertions(+), 28 deletions(-) diff --git a/apps/web/components/chat/message/agent-message.tsx b/apps/web/components/chat/message/agent-message.tsx index ba437f6e..447140cd 100644 --- a/apps/web/components/chat/message/agent-message.tsx +++ b/apps/web/components/chat/message/agent-message.tsx @@ -704,7 +704,7 @@ function KnowledgeBaseConnectAction({
{connectionState === "starting" || connectionState === "waiting" ? (
-
+
{connectionState === "starting" ? "Opening…" : "Waiting…"}
@@ -714,7 +714,7 @@ function KnowledgeBaseConnectAction({ onClick={() => stopWaiting("Connection cancelled. You can try again.") } - className="h-8 rounded-md px-2 text-[12px] font-medium text-[#737373] transition-colors hover:bg-white/[0.05] hover:text-[#FAFAFA]" + className="h-9 rounded-full px-3 text-[12px] font-medium text-[#737373] transition-colors hover:bg-[#0D121A] hover:text-[#FAFAFA]" > Cancel @@ -725,7 +725,7 @@ function KnowledgeBaseConnectAction({ type="button" disabled={disabled} onClick={() => void connect(true)} - className="inline-flex h-8 items-center rounded-md bg-[#4BA0FA] px-3 text-[14px] font-medium text-black transition-colors hover:bg-[#4BA0FA]/90 disabled:cursor-not-allowed disabled:opacity-50" + className="inline-flex h-9 min-w-[116px] items-center justify-center rounded-full bg-[#0D121A] px-5 text-[14px] font-medium text-[#FAFAFA] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] transition-opacity hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50" > {connectionState === "error" ? "Try again" : buttonLabel} @@ -798,33 +798,42 @@ function NovaConnectorCard({ : displayedConnector.description return ( -
-
- -
-

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

- {statusText ? ( -

- {statusText} -

- ) : null} +
+
+
+
+ {isUpgrade ? ( + + Pro + + ) : null} +
+
+

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

+ {statusText ? ( +

+ {statusText} +

+ ) : null} +
+
+ {isUpgrade ? ( +
+ Upgrade +
+ ) : ( + onConnectionsChanged?.()} + onPendingChange={onConnectionPendingChange} + autoStart={autoStartConnection} + attemptKey={connectionAttemptKey} + /> + )}
- {isUpgrade ? ( - - Pro - - ) : ( - onConnectionsChanged?.()} - onPendingChange={onConnectionPendingChange} - autoStart={autoStartConnection} - attemptKey={connectionAttemptKey} - /> - )}
) } From 26c2ce8384934bac05c0dc63efb4fd14e0c62fd7 Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Mon, 3 Aug 2026 20:01:27 +0530 Subject: [PATCH 4/4] Delete apps/web/lib/chat-knowledge-connectors.test.ts --- .../web/lib/chat-knowledge-connectors.test.ts | 60 ------------------- 1 file changed, 60 deletions(-) delete mode 100644 apps/web/lib/chat-knowledge-connectors.test.ts diff --git a/apps/web/lib/chat-knowledge-connectors.test.ts b/apps/web/lib/chat-knowledge-connectors.test.ts deleted file mode 100644 index 475196e2..00000000 --- a/apps/web/lib/chat-knowledge-connectors.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, expect, it } from "bun:test" -import { - detectNovaKnowledgeBaseConnectIntent, - deriveKnowledgeConnectorState, - isNovaKnowledgeBaseProvider, -} from "./chat-knowledge-connectors" - -describe("Nova knowledge connector state", () => { - it("reports live document and connection counts", () => { - expect( - deriveKnowledgeConnectorState({ - provider: "notion", - fallbackStatus: "not_connected", - connections: [ - { - provider: "notion", - metadata: { documentCount: 8 }, - lastSyncRun: { - status: "completed", - completedAt: "2026-07-31T12:00:00.000Z", - }, - }, - ], - }), - ).toEqual({ - status: "active", - connectionCount: 1, - documentCount: 8, - lastSyncStatus: "completed", - lastSyncAt: "2026-07-31T12:00:00.000Z", - }) - }) - - it("preserves upgrade restrictions while refreshing connection state", () => { - expect( - deriveKnowledgeConnectorState({ - provider: "onedrive", - fallbackStatus: "upgrade_required", - connections: [{ provider: "onedrive" }], - }).status, - ).toBe("upgrade_required") - }) - - it("recognizes only supported knowledge-base providers", () => { - 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() - }) -})