PR: nova alpha release (#670)

Co-authored-by: Dhravya Shah <dhravya@supermemory.com>
This commit is contained in:
Mahesh Sanikommu 2026-01-13 00:54:56 -08:00 • committed by GitHub
parent ced23c7ea2
commit 645f89310c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
121 changed files with 21683 additions and 322 deletions

View file

@ -32,6 +32,12 @@ export default defineBackground(() => {
contexts: ["selection", "page", "link"],
})
browser.contextMenus.create({
id: CONTEXT_MENU_IDS.SEARCH_SUPERMEMORY,
title: "search supermemory",
contexts: ["selection"],
})
if (details.reason === "install") {
await trackEvent("extension_installed", {
reason: details.reason,
@ -67,6 +73,22 @@ export default defineBackground(() => {
}
}
}
if (info.menuItemId === CONTEXT_MENU_IDS.SEARCH_SUPERMEMORY) {
if (tab?.id && info.selectionText) {
try {
await browser.tabs.sendMessage(tab.id, {
action: MESSAGE_TYPES.OPEN_SEARCH_PANEL,
data: info.selectionText,
})
} catch (error) {
console.error(
"Failed to send search message to content script:",
error,
)
}
}
}
})
// Send message to current active tab.
@ -296,6 +318,31 @@ export default defineBackground(() => {
})()
return true
}
if (message.action === MESSAGE_TYPES.SEARCH_SELECTION) {
;(async () => {
try {
const query = message.data as string
const responseData = await searchMemories(query)
await trackEvent(POSTHOG_EVENT_KEY.SELECTION_SEARCH_TRIGGERED, {
query_length: query.length,
})
sendResponse({ success: true, data: responseData })
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error"
const isAuthError =
errorMessage.includes("Authentication") ||
errorMessage.includes("token")
sendResponse({
success: false,
error: errorMessage,
isAuthError,
})
}
})()
return true
}
},
)
})

View file

@ -2,9 +2,21 @@ import { DOMAINS, MESSAGE_TYPES } from "../../utils/constants"
import { DOMUtils } from "../../utils/ui-components"
import { initializeChatGPT } from "./chatgpt"
import { initializeClaude } from "./claude"
import { saveMemory, setupGlobalKeyboardShortcut, setupStorageListener } from "./shared"
import {
handleOpenSearchPanel,
initializeSelectionSearch,
} from "./selection-search"
import {
saveMemory,
setupGlobalKeyboardShortcut,
setupStorageListener,
} from "./shared"
import { initializeT3 } from "./t3"
import { handleTwitterNavigation, initializeTwitter, updateTwitterImportUI } from "./twitter"
import {
handleTwitterNavigation,
initializeTwitter,
updateTwitterImportUI,
} from "./twitter"
export default defineContentScript({
matches: ["<all_urls>"],
@ -15,6 +27,8 @@ export default defineContentScript({
DOMUtils.showToast(message.state)
} else if (message.action === MESSAGE_TYPES.SAVE_MEMORY) {
await saveMemory()
} else if (message.action === MESSAGE_TYPES.OPEN_SEARCH_PANEL) {
handleOpenSearchPanel(message.data as string)
} else if (message.type === MESSAGE_TYPES.IMPORT_UPDATE) {
updateTwitterImportUI(message)
} else if (message.type === MESSAGE_TYPES.IMPORT_DONE) {
@ -57,6 +71,9 @@ export default defineContentScript({
initializeT3()
initializeTwitter()
// Initialize universal selection search
initializeSelectionSearch()
// Start observing for dynamic changes
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", observeForDynamicChanges)
@ -64,4 +81,4 @@ export default defineContentScript({
observeForDynamicChanges()
}
},
})
})

View file

@ -0,0 +1,647 @@
import { ELEMENT_IDS, MESSAGE_TYPES, UI_CONFIG } from "../../utils/constants"
// State
let currentQuery = ""
let fabElement: HTMLElement | null = null
let panelElement: HTMLElement | null = null
let selectedResults: Set<number> = new Set()
/**
* Get the selection rectangle for positioning the FAB
*/
function getSelectionRect(): DOMRect | null {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return null
const range = selection.getRangeAt(0)
return range.getBoundingClientRect()
}
/**
* Check if the selection is inside our extension UI
*/
function isSelectionInsideExtensionUI(): boolean {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return false
const anchorNode = selection.anchorNode
if (!anchorNode) return false
const element =
anchorNode.nodeType === Node.ELEMENT_NODE
? (anchorNode as Element)
: anchorNode.parentElement
if (!element) return false
// Check if selection is inside FAB or panel
return (
!!element.closest(`#${ELEMENT_IDS.SELECTION_SEARCH_FAB}`) ||
!!element.closest(`#${ELEMENT_IDS.SELECTION_SEARCH_PANEL}`)
)
}
/**
* Create the floating action button (FAB)
*/
function createFAB(): HTMLElement {
const fab = document.createElement("div")
fab.id = ELEMENT_IDS.SELECTION_SEARCH_FAB
const iconUrl = browser.runtime.getURL("/icon-16.png")
fab.innerHTML = `
<img src="${iconUrl}" width="16" height="16" alt="Search" style="border-radius: 2px;" />
<span>Search</span>
`
fab.style.cssText = `
position: fixed;
z-index: 2147483646;
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background: #05070A;
color: #ffffff;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 13px;
font-weight: 500;
cursor: pointer;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
transition: all 0.15s ease;
user-select: none;
`
fab.addEventListener("mouseenter", () => {
fab.style.background = "#0F151F"
fab.style.borderColor = "rgba(255, 255, 255, 0.2)"
})
fab.addEventListener("mouseleave", () => {
fab.style.background = "#05070A"
fab.style.borderColor = "rgba(255, 255, 255, 0.1)"
})
fab.addEventListener("click", (e) => {
e.preventDefault()
e.stopPropagation()
triggerSearch()
})
return fab
}
/**
* Show the FAB near the selection
*/
function showFAB(rect: DOMRect, text: string) {
hideFAB()
currentQuery = text
fabElement = createFAB()
// Position FAB above the selection, centered
const fabWidth = 90 // approximate width
let left = rect.left + rect.width / 2 - fabWidth / 2
let top = rect.top - 40
// Ensure FAB stays within viewport
if (left < 10) left = 10
if (left + fabWidth > window.innerWidth - 10) {
left = window.innerWidth - fabWidth - 10
}
if (top < 10) {
// Show below selection if not enough space above
top = rect.bottom + 10
}
fabElement.style.left = `${left}px`
fabElement.style.top = `${top}px`
document.body.appendChild(fabElement)
}
/**
* Hide the FAB
*/
export function hideFAB() {
if (fabElement) {
fabElement.remove()
fabElement = null
}
}
/**
* Trigger search with the current query
*/
async function triggerSearch() {
if (!currentQuery) return
hideFAB()
showPanel(currentQuery, "loading")
try {
const response = await browser.runtime.sendMessage({
action: MESSAGE_TYPES.SEARCH_SELECTION,
data: currentQuery,
})
if (response.success) {
showPanel(currentQuery, "results", response.data)
} else if (response.isAuthError) {
showPanel(currentQuery, "auth_error")
} else {
showPanel(currentQuery, "error", null, response.error)
}
} catch (error) {
console.error("Search failed:", error)
showPanel(
currentQuery,
"error",
null,
error instanceof Error ? error.message : "Search failed",
)
}
}
/**
* Create and show the search results panel
*/
function showPanel(
query: string,
state: "loading" | "results" | "error" | "auth_error",
data?: unknown,
errorMessage?: string,
) {
hidePanel()
selectedResults.clear()
panelElement = document.createElement("div")
panelElement.id = ELEMENT_IDS.SELECTION_SEARCH_PANEL
panelElement.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 2147483647;
width: 420px;
max-width: 90vw;
max-height: 70vh;
background: #05070A;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
display: flex;
flex-direction: column;
overflow: hidden;
`
// Header
const header = document.createElement("div")
header.style.cssText = `
padding: 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
`
const iconUrl = browser.runtime.getURL("/icon-16.png")
header.innerHTML = `
<div style="display: flex; align-items: center; gap: 8px;">
<img src="${iconUrl}" width="20" height="20" alt="supermemory" style="border-radius: 4px;" />
<span style="font-size: 14px; font-weight: 600; color: #ffffff;">Search Results</span>
</div>
<button id="sm-panel-close" style="
background: transparent;
border: none;
color: #737373;
cursor: pointer;
padding: 4px;
font-size: 18px;
line-height: 1;
transition: color 0.15s;
">×</button>
`
panelElement.appendChild(header)
// Query display
const queryDisplay = document.createElement("div")
queryDisplay.style.cssText = `
padding: 12px 16px;
background: rgba(91, 126, 245, 0.04);
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
flex-shrink: 0;
`
queryDisplay.innerHTML = `
<div style="font-size: 12px; color: #737373; margin-bottom: 4px;">Searching for:</div>
<div style="font-size: 13px; color: #ffffff; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${escapeHtml(query)}</div>
`
panelElement.appendChild(queryDisplay)
// Content area
const content = document.createElement("div")
content.id = "sm-panel-content"
content.style.cssText = `
flex: 1;
overflow-y: auto;
padding: 16px;
min-height: 150px;
`
if (state === "loading") {
content.innerHTML = `
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 150px; gap: 12px;">
<div style="width: 24px; height: 24px; border: 2px solid rgba(255,255,255,0.1); border-top-color: #5BD3FB; border-radius: 50%; animation: sm-spin 0.8s linear infinite;"></div>
<span style="font-size: 14px; color: #737373;">Searching memories...</span>
</div>
`
} else if (state === "auth_error") {
content.innerHTML = `
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 150px; gap: 16px; text-align: center;">
<div style="font-size: 14px; color: #ef4444;">Sign in required</div>
<p style="font-size: 13px; color: #737373; margin: 0;">Please sign in to supermemory to search your memories.</p>
<button id="sm-sign-in-btn" style="
padding: 10px 20px;
background: linear-gradient(182.37deg, #0ff0d2 -91.53%, #5bd3fb -67.8%, #1e0ff0 95.17%);
color: #ffffff;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
">Sign in</button>
</div>
`
} else if (state === "error") {
content.innerHTML = `
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 150px; gap: 12px; text-align: center;">
<div style="color: #ef4444; font-size: 14px;">Search failed</div>
<p style="font-size: 13px; color: #737373; margin: 0;">${escapeHtml(errorMessage || "An error occurred")}</p>
</div>
`
} else if (state === "results") {
const response = data as {
searchResults?: { results?: Array<{ memory?: string; id?: string }> }
}
const results = response?.searchResults?.results || []
if (results.length === 0) {
content.innerHTML = `
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 150px; gap: 12px; text-align: center;">
<div style="font-size: 14px; color: #737373;">No memories found</div>
<p style="font-size: 13px; color: #525966; margin: 0;">Try a different search query.</p>
</div>
`
} else {
content.innerHTML = results
.map(
(result, index) => `
<div class="sm-result-item" data-index="${index}" style="
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px;
background: rgba(91, 126, 245, 0.04);
border-radius: 8px;
margin-bottom: 8px;
cursor: pointer;
transition: background 0.15s;
border: 1px solid transparent;
">
<input type="checkbox" data-index="${index}" style="
margin-top: 2px;
width: 16px;
height: 16px;
cursor: pointer;
accent-color: #5BD3FB;
" />
<div style="flex: 1; min-width: 0;">
<p style="font-size: 13px; color: #e5e5e5; margin: 0; line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;">${escapeHtml(result.memory || "")}</p>
</div>
</div>
`,
)
.join("")
}
}
panelElement.appendChild(content)
// Footer with copy button (only for results)
if (state === "results") {
const response = data as {
searchResults?: { results?: Array<{ memory?: string }> }
}
const results = response?.searchResults?.results || []
if (results.length > 0) {
const footer = document.createElement("div")
footer.style.cssText = `
padding: 12px 16px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
`
footer.innerHTML = `
<span id="sm-selected-count" style="font-size: 13px; color: #737373;">0 selected</span>
<button id="sm-copy-btn" style="
padding: 10px 16px;
background: linear-gradient(182.37deg, #0ff0d2 -91.53%, #5bd3fb -67.8%, #1e0ff0 95.17%);
color: #ffffff;
border: none;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
opacity: 0.5;
transition: opacity 0.15s;
" disabled>Copy selected</button>
`
panelElement.appendChild(footer)
}
}
// Add animations style
if (!document.getElementById("sm-panel-styles")) {
const style = document.createElement("style")
style.id = "sm-panel-styles"
style.textContent = `
@keyframes sm-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.sm-result-item:hover {
background: rgba(91, 126, 245, 0.08) !important;
}
.sm-result-item.selected {
border-color: rgba(91, 190, 251, 0.3) !important;
background: rgba(91, 126, 245, 0.08) !important;
}
`
document.head.appendChild(style)
}
document.body.appendChild(panelElement)
// Event listeners
setupPanelEventListeners(data)
}
/**
* Setup event listeners for the panel
*/
function setupPanelEventListeners(data: unknown) {
if (!panelElement) return
// Close button
const closeBtn = panelElement.querySelector("#sm-panel-close")
closeBtn?.addEventListener("click", hidePanel)
// Sign in button
const signInBtn = panelElement.querySelector("#sm-sign-in-btn")
signInBtn?.addEventListener("click", () => {
window.open(
import.meta.env.PROD
? "https://app.supermemory.ai/login"
: "http://localhost:3000/login",
"_blank",
)
})
// Result item checkboxes
const checkboxes = panelElement.querySelectorAll(
'.sm-result-item input[type="checkbox"]',
)
checkboxes.forEach((checkbox) => {
checkbox.addEventListener("change", (e) => {
const target = e.target as HTMLInputElement
const index = Number.parseInt(target.dataset.index || "0", 10)
const item = target.closest(".sm-result-item") as HTMLElement
if (target.checked) {
selectedResults.add(index)
item?.classList.add("selected")
} else {
selectedResults.delete(index)
item?.classList.remove("selected")
}
updateSelectionUI()
})
})
// Result item click (toggle checkbox)
const items = panelElement.querySelectorAll(".sm-result-item")
items.forEach((item) => {
item.addEventListener("click", (e) => {
const target = e.target as HTMLElement
if (target.tagName === "INPUT") return
const checkbox = item.querySelector(
'input[type="checkbox"]',
) as HTMLInputElement
if (checkbox) {
checkbox.checked = !checkbox.checked
checkbox.dispatchEvent(new Event("change"))
}
})
})
// Copy button
const copyBtn = panelElement.querySelector("#sm-copy-btn")
copyBtn?.addEventListener("click", () => {
copySelectedResults(data)
})
// Close on escape
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
hidePanel()
}
}
document.addEventListener("keydown", handleKeyDown)
// Close on outside click
const handleOutsideClick = (e: MouseEvent) => {
if (panelElement && !panelElement.contains(e.target as Node)) {
hidePanel()
}
}
setTimeout(() => {
document.addEventListener("click", handleOutsideClick)
}, 100)
// Cleanup listeners when panel is removed
const observer = new MutationObserver(() => {
if (!document.contains(panelElement)) {
document.removeEventListener("keydown", handleKeyDown)
document.removeEventListener("click", handleOutsideClick)
observer.disconnect()
}
})
observer.observe(document.body, { childList: true })
}
/**
* Update the selection count UI
*/
function updateSelectionUI() {
const countEl = document.getElementById("sm-selected-count")
const copyBtn = document.getElementById("sm-copy-btn") as HTMLButtonElement
if (countEl) {
countEl.textContent = `${selectedResults.size} selected`
}
if (copyBtn) {
copyBtn.disabled = selectedResults.size === 0
copyBtn.style.opacity = selectedResults.size === 0 ? "0.5" : "1"
}
}
/**
* Copy selected results to clipboard
*/
async function copySelectedResults(data: unknown) {
const response = data as {
searchResults?: { results?: Array<{ memory?: string }> }
}
const results = response?.searchResults?.results || []
const selectedMemories = Array.from(selectedResults)
.sort((a, b) => a - b)
.map((index) => results[index]?.memory)
.filter(Boolean)
if (selectedMemories.length === 0) return
// Format the copied content
const formattedContent = selectedMemories
.map((memory, i) => `${i + 1}. ${memory}`)
.join("\n\n")
try {
await navigator.clipboard.writeText(formattedContent)
// Show copied feedback
const copyBtn = document.getElementById("sm-copy-btn")
if (copyBtn) {
const originalText = copyBtn.textContent
copyBtn.textContent = "Copied!"
setTimeout(() => {
if (copyBtn) copyBtn.textContent = originalText
}, 1500)
}
// Track event
browser.runtime.sendMessage({
action: MESSAGE_TYPES.CAPTURE_PROMPT,
data: {
prompt: `Copied ${selectedMemories.length} memories`,
platform: "selection_search",
source: "copy_selected",
},
})
} catch (error) {
console.error("Failed to copy to clipboard:", error)
}
}
/**
* Hide the panel
*/
export function hidePanel() {
if (panelElement) {
panelElement.remove()
panelElement = null
}
selectedResults.clear()
}
/**
* Escape HTML to prevent XSS
*/
function escapeHtml(text: string): string {
const div = document.createElement("div")
div.textContent = text
return div.innerHTML
}
/**
* Handle selection change
*/
function handleSelectionChange() {
const selection = window.getSelection()
const text = selection?.toString().trim() || ""
// Hide FAB if selection is empty or inside extension UI
if (
!text ||
text.length < UI_CONFIG.SELECTION_MIN_LENGTH ||
text.length > UI_CONFIG.SELECTION_MAX_LENGTH ||
isSelectionInsideExtensionUI()
) {
hideFAB()
return
}
const rect = getSelectionRect()
if (rect && rect.width > 0 && rect.height > 0) {
showFAB(rect, text)
}
}
/**
* Handle message from background to open search panel
*/
export function handleOpenSearchPanel(query: string) {
currentQuery = query
hideFAB()
triggerSearch()
}
/**
* Initialize selection search functionality
*/
export function initializeSelectionSearch() {
// Listen for mouseup to detect selection
document.addEventListener("mouseup", () => {
// Small delay to ensure selection is complete
setTimeout(handleSelectionChange, 10)
})
// Listen for keyup for keyboard selection
document.addEventListener("keyup", (e) => {
if (e.shiftKey) {
setTimeout(handleSelectionChange, 10)
}
})
// Hide FAB when clicking elsewhere
document.addEventListener("mousedown", (e) => {
const target = e.target as HTMLElement
if (
fabElement &&
!fabElement.contains(target) &&
!panelElement?.contains(target)
) {
// Don't hide immediately to allow FAB click
setTimeout(() => {
const selection = window.getSelection()
if (!selection || selection.toString().trim().length === 0) {
hideFAB()
}
}, 100)
}
})
}

View file

@ -41,7 +41,7 @@ const Tooltip = ({
type="button"
onMouseEnter={() => setIsVisible(true)}
onMouseLeave={() => setIsVisible(false)}
className="cursor-help bg-transparent border-none p-0 text-gray-400 hover:text-gray-600 transition-colors"
className="cursor-help bg-transparent border-none p-0 text-[#737373] transition-colors"
>
<svg
width="14"
@ -60,7 +60,7 @@ const Tooltip = ({
</svg>
</button>
{isVisible && (
<div className="absolute z-50 px-2 py-1 text-xs text-white bg-gray-800 rounded shadow-lg bottom-full right-0 mb-1 max-w-xs break-words">
<div className="absolute z-50 px-2 py-1 text-xs text-white bg-gray-800 rounded shadow-lg bottom-full right-0 mb-1 max-w-xs wrap-break-word">
{content}
<div className="absolute top-full right-4 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-800" />
</div>
@ -266,7 +266,7 @@ function App() {
<div className="flex items-center justify-between gap-3 p-2.5 border-b border-gray-200 relative">
<img
alt="supermemory"
className="w-8 h-8 flex-shrink-0"
className="w-8 h-8 shrink-0"
src="./dark-transparent.svg"
style={{ width: "80%", height: "45px" }}
/>
@ -302,49 +302,52 @@ function App() {
}
return (
<div className="w-80 p-0 font-[Space_Grotesk,-apple-system,BlinkMacSystemFont,Segoe_UI,Roboto,sans-serif] bg-white rounded-lg relative overflow-hidden">
<div className="flex items-center justify-between gap-3 p-2.5 border-b border-gray-200 relative">
<img
alt="supermemory"
className="w-8 h-8 flex-shrink-0"
src="./dark-transparent.svg"
style={{ width: "80%", height: "45px" }}
/>
<div
className="w-80 p-0 font-[Space_Grotesk,-apple-system,BlinkMacSystemFont,Segoe_UI,Roboto,sans-serif] bg-white rounded-lg relative overflow-hidden"
style={{
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
boxShadow:
"1.5px 1.5px 20px 0 rgba(0, 0, 0, 0.65), 1px 1.5px 2px 0 rgba(128, 189, 255, 0.07) inset, -0.5px -1.5px 4px 0 rgba(0, 35, 73, 0.40) inset",
}}
>
<div className="flex items-center justify-between gap-3 p-2.5 relative">
<div className="text-white text-lg font-semibold ml-2">supermemory</div>
{userSignedIn && (
<button
className="bg-none border-none text-base cursor-pointer text-gray-500 p-1 rounded transition-colors duration-200 hover:text-black hover:bg-gray-100"
className="bg-none border-none text-base cursor-pointer text-gray-500 p-1 rounded transition-colors duration-200"
onClick={handleSignOut}
title="Logout"
type="button"
>
<svg
width="19"
height="18"
viewBox="0 0 19 18"
fill="none"
height="16"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<title>Logout</title>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16,17 21,12 16,7" />
<line x1="21" x2="9" y1="12" y2="12" />
<path
d="M17 9H7.5M15 12L18 9L15 6M10 4V3C10 2.46957 9.78929 1.96086 9.41421 1.58579C9.03914 1.21071 8.53043 1 8 1H3C2.46957 1 1.96086 1.21071 1.58579 1.58579C1.21071 1.96086 1 2.46957 1 3V15C1 15.5304 1.21071 16.0391 1.58579 16.4142C1.96086 16.7893 2.46957 17 3 17H8C8.53043 17 9.03914 16.7893 9.41421 16.4142C9.78929 16.0391 10 15.5304 10 15V14"
stroke="#FAFAFA"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
)}
</div>
<div className="p-4 min-h-[250px]">
<div className="p-4 min-h-[250px] pt-1">
{userSignedIn ? (
<div className="text-left">
{/* Tab Navigation */}
<div className="flex bg-gray-100 rounded-lg p-1 mb-4">
<div className="flex bg-[#000000] rounded-xl p-1 mb-4 border border-[#FFFFFF12]">
<button
className={`flex-1 py-2 px-3 bg-transparent border-none rounded-md text-sm font-medium cursor-pointer transition-all duration-200 outline-none appearance-none ${
className={`flex-1 py-2 px-3 bg-transparent border-none rounded-lg text-sm cursor-pointer transition-all duration-200 outline-none appearance-none ${
activeTab === "save"
? "bg-white text-black shadow-sm"
: "text-gray-500 hover:text-gray-700"
? "bg-linear-to-b from-[#0E141C] to-[#0F151F] text-white shadow-sm"
: "text-gray-500 hover:text-white"
}`}
onClick={() => setActiveTab("save")}
type="button"
@ -352,10 +355,10 @@ function App() {
Save
</button>
<button
className={`flex-1 py-2 px-3 bg-transparent border-none rounded-md text-sm font-medium cursor-pointer transition-all duration-200 outline-none appearance-none ${
className={`flex-1 py-2 px-3 bg-transparent border-none rounded-lg text-sm cursor-pointer transition-all duration-200 outline-none appearance-none ${
activeTab === "imports"
? "bg-white text-black shadow-sm"
: "text-gray-500 hover:text-gray-700"
? "bg-linear-to-b from-[#0E141C] to-[#0F151F] text-white shadow-sm"
: "text-gray-500 hover:text-white"
}`}
onClick={() => setActiveTab("imports")}
type="button"
@ -363,10 +366,10 @@ function App() {
Imports
</button>
<button
className={`flex-1 py-2 px-3 bg-transparent border-none rounded-md text-sm font-medium cursor-pointer transition-all duration-200 outline-none appearance-none ${
className={`flex-1 py-2 px-3 bg-transparent border-none rounded-lg text-sm cursor-pointer transition-all duration-200 outline-none appearance-none ${
activeTab === "settings"
? "bg-white text-black shadow-sm"
: "text-gray-500 hover:text-gray-700"
? "bg-linear-to-b from-[#0E141C] to-[#0F151F] text-white shadow-sm"
: "text-gray-500 hover:text-white"
}`}
onClick={() => setActiveTab("settings")}
type="button"
@ -380,11 +383,26 @@ function App() {
<div className="flex flex-col gap-4 min-h-[200px]">
{/* Current Page Info */}
<div className="mb-0">
<div className="bg-gray-50 p-3 rounded-md border border-gray-200">
<h3 className="m-0 mb-1 text-sm font-semibold text-black overflow-hidden text-ellipsis whitespace-nowrap">
<div
className="bg-[#5B7EF50A] p-4 rounded-xl"
style={{
boxShadow:
"2px 2px 2px 0 rgba(0, 0, 0, 0.50) inset, -1px -1px 1px 0 rgba(82, 89, 102, 0.08) inset",
}}
>
<h3
className="m-0 mb-1 text-sm font-semibold text-white overflow-hidden text-ellipsis whitespace-nowrap"
style={{
background:
"linear-gradient(94deg, #369BFD 4.8%, #36FDFD 77.04%, #36FDB5 143.99%)",
backgroundClip: "text",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
}}
>
{currentTitle || "Current Page"}
</h3>
<p className="m-0 text-xs text-gray-500 overflow-hidden text-ellipsis whitespace-nowrap">
<p className="m-0 text-xs text-[#737373] overflow-hidden text-ellipsis whitespace-nowrap">
{currentUrl}
</p>
</div>
@ -393,36 +411,38 @@ function App() {
{/* Project Selection */}
<div className="mb-0">
<button
className="w-full bg-transparent border-none p-0 cursor-pointer text-left"
className="w-full bg-[#5B7EF50A] border-none p-4 cursor-pointer text-left rounded-xl flex justify-between items-center transition-colors duration-200 hover:bg-[#5B7EF520]"
onClick={handleShowProjectSelector}
type="button"
style={{
boxShadow:
"2px 2px 2px 0 rgba(0, 0, 0, 0.50) inset, -1px -1px 1px 0 rgba(82, 89, 102, 0.08) inset",
}}
>
<div className="flex justify-between items-center p-3 bg-gray-50 rounded-lg border border-gray-200 transition-colors duration-200 hover:bg-gray-200 hover:border-gray-300">
<span className="text-sm font-medium text-gray-600">
Save to project:
<span className="text-sm font-medium text-[#737373]">
Save to project:
</span>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-[120px]">
{defaultProject
? defaultProject.name
: "Default Project"}
</span>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-black overflow-hidden text-ellipsis whitespace-nowrap max-w-[120px]">
{defaultProject
? defaultProject.name
: "Default Project"}
</span>
<svg
aria-label="Select project"
className="text-gray-500 flex-shrink-0 transition-transform duration-200 hover:text-gray-700 hover:translate-x-0.5"
fill="none"
height="16"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
width="16"
>
<title>Select project</title>
<path d="M9 18l6-6-6-6" />
</svg>
</div>
<svg
aria-label="Select project"
className="text-white shrink-0 transition-transform duration-200 hover:text-gray-700 transform rotate-90"
fill="none"
height="16"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
width="16"
>
<title>Select project</title>
<path d="M9 18l6-6-6-6" />
</svg>
</div>
</button>
</div>
@ -430,12 +450,43 @@ function App() {
{/* Save Button at Bottom */}
<div className="mt-auto pt-4">
<button
className="w-full py-3 px-6 bg-gray-700 text-white border-none rounded-3xl text-base font-medium cursor-pointer transition-colors duration-200 hover:bg-gray-800 disabled:bg-gray-400 disabled:cursor-not-allowed"
className="w-full py-3 px-6 text-white border-none rounded-xl text-base font-medium cursor-pointer transition-colors duration-200 hover:bg-gray-800 disabled:bg-gray-400 disabled:cursor-not-allowed flex items-center justify-center gap-3"
disabled={saving}
onClick={handleSaveCurrentPage}
style={{
background:
"linear-gradient(182.37deg, #0ff0d2 -91.53%, #5bd3fb -67.8%, #1e0ff0 95.17%)",
boxShadow:
"1px 1px 2px 0px #1A88FF inset, 0 2px 10px 0 rgba(5, 1, 0, 0.20)",
}}
type="button"
>
{saving ? "Saving..." : "Save Current Page"}
<svg
width="20"
height="16"
viewBox="0 0 20 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<title>Add to Supermemory</title>
<g clip-path="url(#clip0_2_8851)">
<path
d="M19.4295 6.3108H12.1691V0H9.82324V6.84734C9.82324 7.57459 10.1103 8.27304 10.6206 8.78766L16.549 14.7664L18.2077 13.0936L13.8291 8.6779H19.4309V6.31219L19.4295 6.3108Z"
fill="#FAFAFA"
/>
<path
d="M1.08945 2.90808L5.46808 7.32387H-0.133789V9.68958H7.12669V16.0003H9.4725V9.15304C9.4725 8.42574 9.18541 7.72728 8.67512 7.21272L2.74809 1.23535L1.08945 2.90808Z"
fill="#FAFAFA"
/>
</g>
<defs>
<clipPath id="clip0_2_8851">
<rect width="19.7333" height="16" fill="white" />
</clipPath>
</defs>
</svg>
{saving ? "Saving..." : "Add to supermemory"}
</button>
</div>
</div>
@ -445,7 +496,11 @@ function App() {
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<button
className="w-full py-3 px-3 bg-white text-black border border-gray-200 rounded-md text-sm font-medium cursor-pointer flex items-center justify-start transition-colors duration-200 hover:bg-gray-50"
className="w-full p-4 bg-[#5B7EF50A] text-white border-none rounded-xl text-sm cursor-pointer flex items-center justify-start transition-colors duration-200 hover:bg-[#5B7EF520]"
style={{
boxShadow:
"2px 2px 2px 0 rgba(0, 0, 0, 0.50) inset, -1px -1px 1px 0 rgba(82, 89, 102, 0.08) inset",
}}
onClick={() => {
chrome.tabs.create({
url: "https://chatgpt.com/#settings/Personalization",
@ -453,20 +508,22 @@ function App() {
}}
type="button"
>
<svg
aria-label="ChatGPT Logo"
className="w-4.5 h-4.5 flex-shrink-0 mr-2"
fill="currentColor"
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<title>OpenAI</title>
<path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z" />
</svg>
<div className="text-left">
<p>Import ChatGPT Memories</p>
<p className="m-0 text-[10px] text-gray-500 leading-tight">
<p className="flex items-center gap-2 font-medium">
<svg
aria-label="ChatGPT Logo"
className="w-3 h-3.5 shrink-0"
fill="currentColor"
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<title>OpenAI</title>
<path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z" />
</svg>
Import ChatGPT Memories
</p>
<p className="m-0 text-[14px] text-[#737373] leading-tight">
open 'manage', save your memories to supermemory
</p>
</div>
@ -475,7 +532,11 @@ function App() {
<div className="flex flex-col gap-2">
<button
className="w-full py-3 px-3 bg-white text-black border border-gray-200 rounded-md text-sm font-medium cursor-pointer flex items-center justify-center transition-colors duration-200 outline-none appearance-none hover:bg-gray-50 focus:outline-none"
className="w-full p-4 bg-[#5B7EF50A] text-white border-none rounded-xl text-sm cursor-pointer flex items-center justify-center transition-colors duration-200 outline-none appearance-none hover:bg-[#5B7EF520] focus:outline-none"
style={{
boxShadow:
"2px 2px 2px 0 rgba(0, 0, 0, 0.50) inset, -1px -1px 1px 0 rgba(82, 89, 102, 0.08) inset",
}}
onClick={async () => {
const [activeTab] = await chrome.tabs.query({
active: true,
@ -494,19 +555,21 @@ function App() {
}}
type="button"
>
<svg
aria-label="X Twitter Logo"
className="w-4.5 h-4.5 flex-shrink-0 mr-2"
fill="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<title>X Twitter Logo</title>
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
<div className="text-left">
<p>Import X/Twitter Bookmarks</p>
<p className="m-0 text-[10px] text-gray-500 leading-tight">
<p className="flex items-center gap-2 font-medium">
<svg
aria-label="X Twitter Logo"
className="w-3 h-3.5 shrink-0"
fill="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<title>X Twitter Logo</title>
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
Import X/Twitter Bookmarks
</p>
<p className="m-0 text-[14px] text-[#737373] leading-tight">
Click on supermemory on top right to import bookmarks
</p>
</div>
@ -515,28 +578,28 @@ function App() {
</div>
</div>
) : (
<div className="flex flex-col gap-4 min-h-[200px]">
<div className="flex flex-col gap-4 min-h-[200px] pl-1">
{/* Account Section */}
<div>
<h3 className="text-base font-semibold text-black mb-3">
Account
</h3>
<div className="p-3 bg-gray-50 rounded-lg border border-gray-200">
<div className="flex flex-col gap-2">
{loadingUserData ? (
<div className="text-sm text-gray-500">
<div className="text-sm text-[#737373]">
Loading account data...
</div>
) : userData?.email ? (
<div className="flex justify-between items-center">
<span className="text-xs font-medium text-black">
Email
</span>
<span className="text-sm text-gray-600">
<>
<span className="font-medium text-base">Email</span>
<span
className="text-sm text-[#525966] p-3 rounded-xl bg-[#5B7EF50A]"
style={{
border: "1px solid rgba(255, 255, 255, 0.07)",
}}
>
{userData.email}
</span>
</div>
</>
) : (
<div className="text-sm text-gray-500">
<div className="text-sm text-[#737373]">
No email found
</div>
)}
@ -545,13 +608,13 @@ function App() {
{/* Chat Integration Section */}
<div className="mb-4">
<h3 className="text-base font-semibold text-black mb-3">
<h3 className="text-base font-semibold mb-3">
Chat Integration
</h3>
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg border border-gray-200 mb-3">
<div className="flex items-center">
<div className="flex items-center justify-between p-3 rounded-xl bg-[#5B7EF50A] mb-3">
<div className="flex items-center text-[#737373]">
<Tooltip content="Automatically search your memories while typing in chat apps">
<span className="text-sm font-medium text-black cursor-help">
<span className="text-sm font-medium cursor-help">
Auto Search Memories
</span>
</Tooltip>
@ -565,13 +628,13 @@ function App() {
}
type="checkbox"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-gray-700" />
<div className="w-11 h-6 bg-[#21212180] peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-[#262A30] after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[#06080B] peer-checked:after:bg-[#15418A]" />
</label>
</div>
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg border border-gray-200">
<div className="flex items-center">
<div className="flex items-center justify-between p-3 rounded-xl bg-[#5B7EF50A]">
<div className="flex items-center text-[#737373]">
<Tooltip content="Automatically save your prompts as memories in chat apps">
<span className="text-sm font-medium text-black cursor-help">
<span className="text-sm font-medium cursor-help">
Auto Capture Prompts
</span>
</Tooltip>
@ -585,7 +648,7 @@ function App() {
}
type="checkbox"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-gray-700" />
<div className="w-11 h-6 bg-[#21212180] peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-[#262A30] after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[#06080B] peer-checked:after:bg-[#15418A]" />
</label>
</div>
</div>
@ -593,8 +656,8 @@ function App() {
)}
{showProjectSelector && (
<div className="absolute inset-0 bg-white rounded-lg z-[1000] shadow-xl flex flex-col">
<div className="flex justify-between items-center p-4 border-b border-gray-200 text-base font-semibold text-black flex-shrink-0">
<div className="absolute inset-0 bg-white rounded-lg z-1000 shadow-xl flex flex-col">
<div className="flex justify-between items-center p-4 border-b border-gray-200 text-base font-semibold text-black shrink-0">
<span>Select the Project</span>
<button
className="bg-transparent border-none text-xl cursor-pointer text-gray-500 p-0 w-6 h-6 flex items-center justify-center hover:text-black"
@ -620,7 +683,7 @@ function App() {
type="button"
>
<div className="flex flex-col flex-1 gap-0.5">
<span className="text-sm font-medium text-black break-words leading-tight">
<span className="text-sm font-medium text-black wrap-break-word leading-tight">
{project.name}
</span>
<span className="text-xs text-gray-500">

View file

@ -146,7 +146,10 @@ export async function searchMemories(query: string): Promise<unknown> {
try {
const response = await makeAuthenticatedRequest<unknown>("/v4/search", {
method: "POST",
body: JSON.stringify({ q: query, include: { relatedMemories: true } }),
body: JSON.stringify({
q: query,
include: { relatedMemories: true },
}),
})
return response
} catch (error) {

View file

@ -22,6 +22,8 @@ export const ELEMENT_IDS = {
CLAUDE_INPUT_BAR_ELEMENT: "sm-claude-input-bar-element",
T3_INPUT_BAR_ELEMENT: "sm-t3-input-bar-element",
PROJECT_SELECTION_MODAL: "sm-project-selection-modal",
SELECTION_SEARCH_FAB: "sm-selection-search-fab",
SELECTION_SEARCH_PANEL: "sm-selection-search-panel",
} as const
/**
@ -36,6 +38,8 @@ export const UI_CONFIG = {
OBSERVER_THROTTLE_DELAY: 300, // milliseconds between observer callback executions
ROUTE_CHECK_INTERVAL: 2000, // milliseconds between route change checks
API_REQUEST_TIMEOUT: 10000, // milliseconds for API request timeout
SELECTION_MIN_LENGTH: 3, // minimum characters to show FAB
SELECTION_MAX_LENGTH: 500, // maximum characters to show FAB
} as const
/**
@ -69,10 +73,13 @@ export const MESSAGE_TYPES = {
GET_RELATED_MEMORIES: "sm-get-related-memories",
CAPTURE_PROMPT: "sm-capture-prompt",
FETCH_PROJECTS: "sm-fetch-projects",
SEARCH_SELECTION: "sm-search-selection",
OPEN_SEARCH_PANEL: "sm-open-search-panel",
} as const
export const CONTEXT_MENU_IDS = {
SAVE_TO_SUPERMEMORY: "sm-save-to-supermemory",
SEARCH_SUPERMEMORY: "sm-search-supermemory",
} as const
export const POSTHOG_EVENT_KEY = {
@ -86,4 +93,6 @@ export const POSTHOG_EVENT_KEY = {
CLAUDE_CHAT_MEMORIES_AUTO_SEARCHED: "claude_chat_memories_auto_searched",
CHATGPT_CHAT_MEMORIES_SEARCHED: "chatgpt_chat_memories_searched",
CHATGPT_CHAT_MEMORIES_AUTO_SEARCHED: "chatgpt_chat_memories_auto_searched",
SELECTION_SEARCH_TRIGGERED: "selection_search_triggered",
SELECTION_SEARCH_COPIED: "selection_search_copied",
} as const

View file

@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View file

@ -0,0 +1,479 @@
"use client"
import { signIn } from "@lib/auth"
import { usePostHog } from "@lib/posthog"
import { TextSeparator } from "@repo/ui/components/text-separator"
import { ExternalAuthButton } from "@ui/button/external-auth"
import { Button } from "@ui/components/button"
import { Badge } from "@ui/components/badge"
import { LabeledInput } from "@ui/input/labeled-input"
import { HeadingH3Medium } from "@ui/text/heading/heading-h3-medium"
import { Label1Regular } from "@ui/text/label/label-1-regular"
import { Title1Bold } from "@ui/text/title/title-1-bold"
import { InitialHeader } from "@/components/initial-header"
import { useRouter, useSearchParams } from "next/navigation"
import { useState, useEffect } from "react"
import { motion } from "framer-motion"
import { dmSansClassName } from "@/utils/fonts"
import { cn } from "@lib/utils"
import { Logo } from "@ui/assets/Logo"
function AnimatedGradientBackground() {
return (
<div className="fixed inset-0 z-0 overflow-hidden">
<motion.div
className="absolute top-[20%] left-0 right-0 bottom-0 bg-[url('/onboarding/bg-gradient-0.png')] bg-size-[150%_auto] bg-top bg-no-repeat"
initial={{ y: "100%" }}
animate={{
y: 0,
opacity: [1, 0, 1],
}}
transition={{
y: { duration: 0.75, ease: "easeOut" },
opacity: { duration: 8, repeat: Number.POSITIVE_INFINITY, ease: "easeInOut" },
}}
/>
<motion.div
className="absolute top-[20%] left-0 right-0 bottom-0 bg-[url('/onboarding/bg-gradient-1.png')] bg-size-[150%_auto] bg-top bg-no-repeat"
initial={{ y: "100%" }}
animate={{
y: 0,
opacity: [0, 1, 0],
}}
transition={{
y: { duration: 0.75, ease: "easeOut" },
opacity: { duration: 8, repeat: Number.POSITIVE_INFINITY, ease: "easeInOut" },
}}
/>
<motion.div
className="absolute top-0 left-0 right-0 bottom-0 bg-[url('/bg-rectangle.png')] bg-cover bg-center bg-no-repeat"
transition={{ duration: 0.75, ease: "easeOut", bounce: 0 }}
style={{
mixBlendMode: "soft-light",
opacity: 0.4,
}}
/>
</div>
)
}
function LoginCard({ children }: { children: React.ReactNode }) {
return (
<motion.div
className="flex py-8 px-11 flex-col items-start gap-2 rounded-[22px] bg-linear-to-b from-[#06101F] to-[#030912] shadow-[1.5px_1.5px_20px_0_rgba(0,0,0,0.65),1px_1.5px_2px_0_rgba(128,189,255,0.07)_inset,-0.5px_-1.5px_4px_0_rgba(0,35,73,0.40)_inset]"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.75, ease: "easeOut" }}
>
{children}
</motion.div>
)
}
export default function LoginPage() {
const [email, setEmail] = useState("")
const [submittedEmail, setSubmittedEmail] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [isLoadingEmail, setIsLoadingEmail] = useState(false)
const [error, setError] = useState<string | null>(null)
const [lastUsedMethod, setLastUsedMethod] = useState<string | null>(null)
const router = useRouter()
const posthog = usePostHog()
const params = useSearchParams()
// Get redirect URL from query params
const redirectUrl = params.get("redirect")
// Create callback URL that includes redirect parameter if provided
const getCallbackURL = () => {
const origin = window.location.origin
let finalUrl: URL
if (redirectUrl) {
try {
finalUrl = new URL(redirectUrl, origin)
} catch {
finalUrl = new URL(origin)
}
} else {
finalUrl = new URL(origin)
}
finalUrl.searchParams.set("extension-auth-success", "true")
return finalUrl.toString()
}
// Load last used method from localStorage on mount
useEffect(() => {
const savedMethod = localStorage.getItem("supermemory-last-login-method")
setLastUsedMethod(savedMethod)
}, [])
// Record the pending login method (will be committed after successful auth)
function setPendingLoginMethod(method: string) {
try {
localStorage.setItem("supermemory-pending-login-method", method)
localStorage.setItem(
"supermemory-pending-login-timestamp",
String(Date.now()),
)
} catch {}
}
function isNetworkError(error: unknown): boolean {
if (!(error instanceof Error)) return false
const message = error.message.toLowerCase()
return (
message.includes("load failed") ||
message.includes("networkerror") ||
message.includes("failed to fetch") ||
message.includes("network request failed")
)
}
function getErrorMessage(error: unknown): string {
if (isNetworkError(error)) {
return "Network error. Please check your connection and try again."
}
if (error instanceof Error) {
return error.message
}
return "An unexpected error occurred. Please try again."
}
// If we land back on this page with an error, clear any pending marker
useEffect(() => {
if (params.get("error")) {
try {
localStorage.removeItem("supermemory-pending-login-method")
localStorage.removeItem("supermemory-pending-login-timestamp")
} catch {}
}
}, [params])
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setIsLoading(true)
setIsLoadingEmail(true)
setError(null)
// Track login attempt
posthog.capture("login_attempt", {
method: "magic_link",
email_domain: email.split("@")[1] || "unknown",
})
try {
await signIn.magicLink({
callbackURL: getCallbackURL(),
email,
})
setSubmittedEmail(email)
setPendingLoginMethod("magic_link")
// Track successful magic link send
posthog.capture("login_magic_link_sent", {
email_domain: email.split("@")[1] || "unknown",
})
} catch (error) {
console.error(error)
// Track login failure
posthog.capture("login_failed", {
method: "magic_link",
error: error instanceof Error ? error.message : "Unknown error",
email_domain: email.split("@")[1] || "unknown",
is_network_error: isNetworkError(error),
})
setError(getErrorMessage(error))
setIsLoading(false)
setIsLoadingEmail(false)
return
}
setIsLoading(false)
setIsLoadingEmail(false)
}
const handleSubmitToken = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
setIsLoading(true)
const formData = new FormData(event.currentTarget)
const token = formData.get("token") as string
const callbackURL = getCallbackURL()
router.push(
`${process.env.NEXT_PUBLIC_BACKEND_URL}/api/auth/magic-link/verify?token=${token}&callbackURL=${encodeURIComponent(callbackURL)}`,
)
}
return (
<main className="relative h-screen overflow-hidden">
<AnimatedGradientBackground />
<div className="relative z-10">
<InitialHeader />
<section className="flex flex-col items-center justify-center p-4 space-y-12 sm:p-6 md:p-8 lg:px-20 lg:py-12.5 min-h-[calc(100vh-80px)]">
<div className="text-center">
<div className="text-5xl font-medium">
Never forget anything, anywhere
</div>
<div className="text-5xl font-medium">with supermemory</div>
</div>
{submittedEmail ? (
<LoginCard>
<div className="w-[360px] flex flex-col gap-4 lg:gap-6 min-h-2/3">
<div className="flex flex-col gap-2 text-center lg:text-left">
<Title1Bold className="text-foreground">
Almost there!
</Title1Bold>
<HeadingH3Medium className="text-muted-foreground">
Click the magic link we've sent to{" "}
<span className="text-foreground">{submittedEmail}</span>.
</HeadingH3Medium>
</div>
<TextSeparator text="OR" className={cn(dmSansClassName())} />
<form
className="flex flex-col gap-4 lg:gap-6"
onSubmit={handleSubmitToken}
>
<LabeledInput
inputPlaceholder="your temporary login code"
inputProps={{
name: "token",
required: true,
disabled: isLoading,
"aria-invalid": error ? "true" : "false",
}}
inputType="text"
label="Enter code"
/>
<Button disabled={isLoading} id="verify-token" type="submit">
Verify Token
</Button>
</form>
</div>
</LoginCard>
) : (
<LoginCard>
<div className="w-[360px] flex flex-col" style={{ gap: "12px" }}>
{params.get("error") && (
<div className="text-red-500">
Error: {params.get("error")}. Please try again!
</div>
)}
<div className="flex flex-col gap-3">
{process.env.NEXT_PUBLIC_HOST_ID === "supermemory" ||
!process.env.NEXT_PUBLIC_GOOGLE_AUTH_ENABLED ? (
<div className="relative grow">
<ExternalAuthButton
authIcon={
<svg
className="w-4 h-4 sm:w-5 sm:h-5"
fill="none"
height="25"
viewBox="0 0 24 25"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<title>Google</title>
<path
d="M21.8055 10.2563H21V10.2148H12V14.2148H17.6515C16.827 16.5433 14.6115 18.2148 12 18.2148C8.6865 18.2148 6 15.5283 6 12.2148C6 8.90134 8.6865 6.21484 12 6.21484C13.5295 6.21484 14.921 6.79184 15.9805 7.73434L18.809 4.90584C17.023 3.24134 14.634 2.21484 12 2.21484C6.4775 2.21484 2 6.69234 2 12.2148C2 17.7373 6.4775 22.2148 12 22.2148C17.5225 22.2148 22 17.7373 22 12.2148C22 11.5443 21.931 10.8898 21.8055 10.2563Z"
fill="#FFC107"
/>
<path
d="M3.15234 7.56034L6.43784 9.96984C7.32684 7.76884 9.47984 6.21484 11.9993 6.21484C13.5288 6.21484 14.9203 6.79184 15.9798 7.73434L18.8083 4.90584C17.0223 3.24134 14.6333 2.21484 11.9993 2.21484C8.15834 2.21484 4.82734 4.38334 3.15234 7.56034Z"
fill="#FF3D00"
/>
<path
d="M12.0002 22.2152C14.5832 22.2152 16.9302 21.2267 18.7047 19.6192L15.6097 17.0002C14.5721 17.7897 13.3039 18.2166 12.0002 18.2152C9.39916 18.2152 7.19066 16.5567 6.35866 14.2422L3.09766 16.7547C4.75266 19.9932 8.11366 22.2152 12.0002 22.2152Z"
fill="#4CAF50"
/>
<path
d="M21.8055 10.2563H21V10.2148H12V14.2148H17.6515C17.2571 15.3231 16.5467 16.2914 15.608 17.0003L15.6095 16.9993L18.7045 19.6183C18.4855 19.8173 22 17.2148 22 12.2148C22 11.5443 21.931 10.8898 21.8055 10.2563Z"
fill="#1976D2"
/>
</svg>
}
authProvider="Google"
className="w-full"
disabled={isLoading}
onClick={() => {
if (isLoading) return
setIsLoading(true)
posthog.capture("login_attempt", {
method: "social",
provider: "google",
})
setPendingLoginMethod("google")
signIn
.social({
callbackURL: getCallbackURL(),
provider: "google",
})
.finally(() => {
setIsLoading(false)
})
}}
/>
{lastUsedMethod === "google" && (
<div className="absolute -top-2 -right-2">
<Badge variant="default" className="text-xs">
Last used
</Badge>
</div>
)}
</div>
) : null}
{process.env.NEXT_PUBLIC_HOST_ID === "supermemory" ||
!process.env.NEXT_PUBLIC_GITHUB_AUTH_ENABLED ? (
<div className="relative grow">
<ExternalAuthButton
authIcon={
<svg
className="w-4 h-4 sm:w-5 sm:h-5 text-foreground"
fill="none"
height="25"
viewBox="0 0 26 25"
width="26"
xmlns="http://www.w3.org/2000/svg"
>
<title>Github</title>
<g clipPath="url(#clip0_2579_3356)">
<path
clipRule="evenodd"
d="M12.9635 0.214844C6.20975 0.214844 0.75 5.71484 0.75 12.5191C0.75 17.9581 4.24825 22.5621 9.10125 24.1916C9.708 24.3141 9.93025 23.9268 9.93025 23.6011C9.93025 23.3158 9.91025 22.3381 9.91025 21.3193C6.51275 22.0528 5.80525 19.8526 5.80525 19.8526C5.25925 18.4266 4.45025 18.0601 4.45025 18.0601C3.33825 17.3063 4.53125 17.3063 4.53125 17.3063C5.76475 17.3878 6.412 18.5693 6.412 18.5693C7.50375 20.4433 9.263 19.9138 9.97075 19.5878C10.0718 18.7933 10.3955 18.2433 10.7393 17.9378C8.0295 17.6526 5.1785 16.5933 5.1785 11.8671C5.1785 10.5226 5.6635 9.42259 6.432 8.56709C6.31075 8.26159 5.886 6.99834 6.5535 5.30759C6.5535 5.30759 7.58475 4.98159 9.91 6.57059C10.9055 6.30126 11.9322 6.16425 12.9635 6.16309C13.9948 6.16309 15.046 6.30584 16.0168 6.57059C18.3423 4.98159 19.3735 5.30759 19.3735 5.30759C20.041 6.99834 19.616 8.26159 19.4948 8.56709C20.2835 9.42259 20.7485 10.5226 20.7485 11.8671C20.7485 16.5933 17.8975 17.6321 15.1675 17.9378C15.6125 18.3248 15.9965 19.0581 15.9965 20.2193C15.9965 21.8693 15.9765 23.1936 15.9765 23.6008C15.9765 23.9268 16.199 24.3141 16.8055 24.1918C21.6585 22.5618 25.1568 17.9581 25.1568 12.5191C25.1768 5.71484 19.697 0.214844 12.9635 0.214844Z"
fill="currentColor"
fillRule="evenodd"
/>
</g>
<defs>
<clipPath id="clip0_2579_3356">
<rect
fill="currentColor"
height="24"
transform="translate(0.75 0.214844)"
width="24.5"
/>
</clipPath>
</defs>
</svg>
}
authProvider="Github"
className="w-full"
disabled={isLoading}
onClick={() => {
if (isLoading) return
setIsLoading(true)
posthog.capture("login_attempt", {
method: "social",
provider: "github",
})
setPendingLoginMethod("github")
signIn
.social({
callbackURL: getCallbackURL(),
provider: "github",
})
.finally(() => {
setIsLoading(false)
})
}}
/>
{lastUsedMethod === "github" && (
<div className="absolute -top-2 -right-2">
<Badge variant="default" className="text-xs">
Last used
</Badge>
</div>
)}
</div>
) : null}
</div>
<TextSeparator text="OR" className={cn(dmSansClassName())} />
<div className="flex flex-col gap-6">
<form onSubmit={handleSubmit} className="flex flex-col gap-6">
<LabeledInput
error={error}
inputPlaceholder="your@email.com"
inputProps={{
"aria-invalid": error ? "true" : "false",
disabled: isLoading,
id: "email",
onChange: (e) => {
setEmail(e.target.value)
error && setError(null)
},
required: true,
value: email,
}}
inputType="email"
/>
<div className="relative">
<Button
className="flex justify-center items-center w-full h-[44px] relative gap-3 p-2 rounded-xl"
style={{
background:
"linear-gradient(182.37deg, #0ff0d2 -91.53%, #5bd3fb -67.8%, #1e0ff0 95.17%)",
boxShadow:
"1px 1px 2px 0px #1A88FF inset, 0 2px 10px 0 rgba(5, 1, 0, 0.20)",
}}
disabled={isLoading}
type="submit"
>
<Logo className="size-4" />
{isLoadingEmail
? "Sending login link..."
: "Log in with Supermemory"}
</Button>
{lastUsedMethod === "magic_link" && (
<div className="absolute -top-2 -right-2">
<Badge variant="default" className="text-xs">
Last used
</Badge>
</div>
)}
</div>
</form>
<Label1Regular
className={cn(
"text-center text-xs! text-[#737373B2]",
dmSansClassName(),
)}
>
By continuing, you agree to our{" "}
<span className="inline-block">
<a
className="underline"
href="https://supermemory.ai/terms-of-service"
>
Terms
</a>{" "}
and{" "}
<a
className="underline"
href="https://supermemory.ai/privacy-policy"
>
Privacy Policy
</a>
.
</span>
</Label1Regular>
</div>
</div>
</LoginCard>
)}
</section>
</div>
</main>
)
}

View file

@ -1,12 +1,5 @@
import { LoginPage } from "@repo/ui/pages/login";
import { LoginPage } from "@repo/ui/pages/login"
export default function Page() {
return (
<LoginPage
texts={[
"Your memory, everywhere, with you.",
"Private, secure, and reliable.",
]}
/>
);
return <LoginPage />
}

View file

@ -10,12 +10,14 @@ import { ChromeExtensionButton } from "@/components/chrome-extension-button"
import { ChatInput } from "@/components/chat-input"
import { BackgroundPlus } from "@ui/components/grid-plus"
import { Memories } from "@/components/memories"
import { useFeatureFlagEnabled } from "posthog-js/react"
export default function Page() {
const { user, session } = useAuth()
const { shouldShowOnboarding, isLoading: onboardingLoading } =
useOnboardingStorage()
const router = useRouter()
const flagEnabled = useFeatureFlagEnabled("nova-alpha-access")
useEffect(() => {
const url = new URL(window.location.href)
@ -33,7 +35,10 @@ export default function Page() {
if (sessionToken && userData?.email) {
const encodedToken = encodeURIComponent(sessionToken)
window.postMessage({ token: encodedToken, userData }, window.location.origin)
window.postMessage(
{ token: encodedToken, userData },
window.location.origin,
)
url.searchParams.delete("extension-auth-success")
window.history.replaceState({}, "", url.toString())
}
@ -42,9 +47,13 @@ export default function Page() {
useEffect(() => {
if (user && !onboardingLoading && shouldShowOnboarding()) {
router.push("/onboarding")
if (flagEnabled) {
router.push("/new/onboarding?step=input&flow=welcome")
} else {
router.push("/onboarding")
}
}
}, [user, shouldShowOnboarding, onboardingLoading, router])
}, [user, shouldShowOnboarding, onboardingLoading, router, flagEnabled])
if (!user || onboardingLoading) {
return (

View file

@ -0,0 +1,61 @@
export interface ExaContentResult {
url: string
text: string
title: string
author?: string
}
interface ExaApiResponse {
results: ExaContentResult[]
}
export async function POST(request: Request) {
try {
const { urls } = await request.json()
if (!Array.isArray(urls) || urls.length === 0) {
return Response.json(
{ error: "Invalid input: urls must be a non-empty array" },
{ status: 400 },
)
}
if (!urls.every((url) => typeof url === "string" && url.trim())) {
return Response.json(
{ error: "Invalid input: all urls must be non-empty strings" },
{ status: 400 },
)
}
const response = await fetch("https://api.exa.ai/contents", {
method: "POST",
headers: {
"x-api-key": process.env.EXA_API_KEY ?? "",
"Content-Type": "application/json",
},
body: JSON.stringify({
urls,
text: true,
livecrawl: "fallback",
}),
})
if (!response.ok) {
console.error(
"Exa API request failed:",
response.status,
response.statusText,
)
return Response.json(
{ error: "Failed to fetch content from Exa API" },
{ status: 500 },
)
}
const data: ExaApiResponse = await response.json()
return Response.json({ results: data.results })
} catch (error) {
console.error("Exa API request error:", error)
return Response.json({ error: "Internal server error" }, { status: 500 })
}
}

View file

@ -5,7 +5,7 @@ import "@ui/globals.css"
import { AuthProvider } from "@lib/auth-context"
import { ErrorTrackingProvider } from "@lib/error-tracking"
import { PostHogProvider } from "@lib/posthog"
import { QueryProvider } from "@lib/query-client"
import { QueryProvider } from "../components/query-client"
import { AutumnProvider } from "autumn-js/react"
import { Suspense } from "react"
import { Toaster } from "sonner"
@ -39,8 +39,8 @@ export default function RootLayout({
>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
defaultTheme="dark"
enableSystem={false}
disableTransitionOnChange
>
<AutumnProvider

View file

@ -0,0 +1,22 @@
"use client"
import { useEffect } from "react"
import { useFeatureFlagEnabled } from "posthog-js/react"
import { useRouter } from "next/navigation"
export default function NewLayout({ children }: { children: React.ReactNode }) {
const router = useRouter()
const flagEnabled = useFeatureFlagEnabled("nova-alpha-access")
useEffect(() => {
if (!flagEnabled) {
router.push("/")
}
}, [flagEnabled, router])
if (!flagEnabled) {
return null
}
return <>{children}</>
}

View file

@ -0,0 +1,267 @@
"use client"
import { useSearchParams } from "next/navigation"
import { motion, AnimatePresence } from "motion/react"
import { useState, useEffect } from "react"
import { useAuth } from "@lib/auth-context"
import { cn } from "@lib/utils"
import { InputStep } from "./welcome/input-step"
import { GreetingStep } from "./welcome/greeting-step"
import { WelcomeStep } from "./welcome/welcome-step"
import { ContinueStep } from "./welcome/continue-step"
import { FeaturesStep } from "./welcome/features-step"
import { MemoriesStep } from "./welcome/memories-step"
import { RelatableQuestion } from "./setup/relatable-question"
import { IntegrationsStep } from "./setup/integrations-step"
import { InitialHeader } from "@/components/initial-header"
import { SetupHeader } from "./setup/header"
import { ChatSidebar } from "./setup/chat-sidebar"
import { Logo } from "@ui/assets/Logo"
import NovaOrb from "@/components/nova/nova-orb"
import { AnimatedGradientBackground } from "@/components/new/animated-gradient-background"
function UserSupermemory({ name }: { name: string }) {
return (
<motion.div
className="absolute inset-0 top-[-34px] flex items-center justify-center z-10"
initial={{ opacity: 0, y: 0 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 0 }}
transition={{ duration: 1, ease: "easeOut" }}
>
<Logo className="h-14 text-white" />
<div className="flex flex-col items-start justify-center ml-4">
<p className="text-white text-[25px] font-medium leading-none">
{name.split(" ")[0]}'s
</p>
<p className="text-white font-bold text-4xl leading-none -mt-2">
supermemory
</p>
</div>
</motion.div>
)
}
export default function OnboardingPage() {
const searchParams = useSearchParams()
const { user } = useAuth()
const flow = searchParams.get("flow") as "welcome" | "setup" | null
const step = searchParams.get("step") as string | null
const [name, setName] = useState(user?.name ?? "")
const [isSubmitting, setIsSubmitting] = useState(false)
const [memoryFormData, setMemoryFormData] = useState<{
twitter: string
linkedin: string
description: string
otherLinks: string[]
} | null>(null)
const [showWelcomeContent, setShowWelcomeContent] = useState(false)
const currentFlow = flow || "welcome"
const currentStep = step || "input"
useEffect(() => {
if (user?.name) {
setName(user.name)
localStorage.setItem("username", user.name)
}
}, [user?.name])
useEffect(() => {
if (currentFlow === "welcome" && currentStep === "input") {
setShowWelcomeContent(false)
const timer = setTimeout(() => {
setShowWelcomeContent(true)
}, 1250)
return () => clearTimeout(timer)
}
}, [currentFlow, currentStep])
useEffect(() => {
if (currentFlow !== "welcome") return
const timers: NodeJS.Timeout[] = []
switch (currentStep) {
case "greeting":
timers.push(
setTimeout(() => {
// Auto-advance to welcome step
window.history.replaceState(
null,
"",
"/new/onboarding?flow=welcome&step=welcome",
)
}, 2000),
)
break
case "welcome":
timers.push(
setTimeout(() => {
// Auto-advance to username step
window.history.replaceState(
null,
"",
"/new/onboarding?flow=welcome&step=username",
)
}, 2000),
)
break
}
return () => {
timers.forEach(clearTimeout)
}
}, [currentStep, currentFlow])
const handleSubmit = () => {
localStorage.setItem("username", name)
if (name.trim()) {
setIsSubmitting(true)
window.history.replaceState(
null,
"",
"/new/onboarding?flow=welcome&step=greeting",
)
setIsSubmitting(false)
}
}
const renderWelcomeStep = () => {
switch (currentStep) {
case "input":
return (
<InputStep
key="input"
name={name}
setName={setName}
handleSubmit={handleSubmit}
isSubmitting={isSubmitting}
/>
)
case "greeting":
return <GreetingStep key="greeting" name={name} />
case "welcome":
return <WelcomeStep key="welcome" />
case "username":
return <ContinueStep key="username" />
case "features":
return <FeaturesStep key="features" />
case "memories":
return <MemoriesStep key="memories" onSubmit={setMemoryFormData} />
default:
return null
}
}
const renderSetupStep = () => {
switch (currentStep) {
case "relatable":
return <RelatableQuestion key="relatable" />
case "integrations":
return <IntegrationsStep key="integrations" />
default:
return null
}
}
const isWelcomeFlow = currentFlow === "welcome"
const isSetupFlow = currentFlow === "setup"
const minimizeNovaOrb =
isWelcomeFlow && ["features", "memories"].includes(currentStep)
const novaSize = currentStep === "memories" ? 150 : 300
const showUserSupermemory = isWelcomeFlow && currentStep === "username"
return (
<div className="h-screen overflow-hidden bg-black">
{isWelcomeFlow && (
<InitialHeader
showUserSupermemory={
currentStep === "features" || currentStep === "memories"
}
name={name}
/>
)}
{isSetupFlow && <SetupHeader />}
{isSetupFlow && <AnimatedGradientBackground animateFromBottom={false} />}
{isWelcomeFlow && currentStep === "input" && (
<AnimatedGradientBackground animateFromBottom={true} />
)}
{isWelcomeFlow && showWelcomeContent && (
<div className="fixed inset-0 flex flex-col items-center justify-center overflow-y-auto">
<motion.div
className="absolute inset-0 bg-[url('/bg-rectangle.png')] bg-cover bg-center bg-no-repeat pointer-events-none"
transition={{ duration: 0.75, ease: "easeOut", bounce: 0 }}
style={{
mixBlendMode: "soft-light",
opacity: 0.6,
}}
/>
<motion.div
className={cn(
"absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-10 flex flex-col items-center justify-center",
)}
animate={{
gap: minimizeNovaOrb ? 0 : 16,
}}
transition={{
duration: 0.6,
ease: "easeOut",
}}
>
<motion.div
animate={{
scale:
currentStep === "features"
? 0.7
: currentStep === "memories"
? 0.4
: 1,
padding: minimizeNovaOrb ? 0 : 48,
paddingTop: 0,
}}
transition={{
duration: 0.8,
ease: "easeOut",
delay: 0.2,
}}
className="relative"
>
<NovaOrb size={novaSize} />
{showUserSupermemory && <UserSupermemory name={name} />}
</motion.div>
<AnimatePresence mode="wait">{renderWelcomeStep()}</AnimatePresence>
</motion.div>
</div>
)}
{isSetupFlow && (
<main className="relative min-h-screen">
<div className="relative z-10">
<div className="flex flex-row h-[calc(100vh-90px)] relative">
<div className="flex-1 flex flex-col items-center justify-start p-8">
<AnimatePresence mode="wait">
{renderSetupStep()}
</AnimatePresence>
</div>
<AnimatePresence mode="popLayout">
<ChatSidebar formData={memoryFormData} />
</AnimatePresence>
</div>
</div>
</main>
)}
</div>
)
}

View file

@ -0,0 +1,424 @@
"use client"
import { useState, useEffect, useCallback, useRef } from "react"
import { motion, AnimatePresence } from "motion/react"
import NovaOrb from "@/components/nova/nova-orb"
import { Button } from "@ui/components/button"
import { PanelRightCloseIcon, SendIcon } from "lucide-react"
import { collectValidUrls } from "@/utils/url-helpers"
import { $fetch } from "@lib/api"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/utils/fonts"
interface ChatSidebarProps {
formData: {
twitter: string
linkedin: string
description: string
otherLinks: string[]
} | null
}
export function ChatSidebar({ formData }: ChatSidebarProps) {
const [message, setMessage] = useState("")
const [isChatOpen, setIsChatOpen] = useState(true)
const [messages, setMessages] = useState<
{
message: string
type?: "formData" | "exa" | "memory" | "waiting"
memories?: {
url: string
title: string
description: string
fullContent: string
}[]
url?: string
title?: string
description?: string
}[]
>([])
const [isLoading, setIsLoading] = useState(false)
const displayedMemoriesRef = useRef<Set<string>>(new Set())
const handleSend = () => {
console.log("Message:", message)
setMessage("")
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
const toggleChat = () => {
setIsChatOpen(!isChatOpen)
}
const pollForMemories = useCallback(
async (documentIds: string[]) => {
const maxAttempts = 30 // 30 attempts * 3 seconds = 90 seconds max
const pollInterval = 3000 // 3 seconds
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
const response = await $fetch("@get/documents/:id", {
params: { id: documentIds[0] ?? "" },
disableValidation: true,
})
console.log("response", response)
if (response.data) {
const document = response.data
if (document.memories && document.memories.length > 0) {
const newMemories: {
url: string
title: string
description: string
fullContent: string
}[] = []
document.memories.forEach(
(memory: { memory: string; title?: string }) => {
if (!displayedMemoriesRef.current.has(memory.memory)) {
displayedMemoriesRef.current.add(memory.memory)
newMemories.push({
url: document.url || "",
title: memory.title || document.title || "Memory",
description: memory.memory || "",
fullContent: memory.memory || "",
})
}
},
)
if (newMemories.length > 0 && messages.length < 10) {
setMessages((prev) => [
...prev,
{
message: newMemories
.map((memory) => memory.description)
.join("\n"),
type: "memory" as const,
memories: newMemories,
},
])
}
}
if (document.memories && document.memories.length > 0) {
break
}
}
await new Promise((resolve) => setTimeout(resolve, pollInterval))
} catch (error) {
console.warn("Error polling for memories:", error)
await new Promise((resolve) => setTimeout(resolve, pollInterval))
}
}
},
[messages.length],
)
useEffect(() => {
if (!formData) return
const urls = collectValidUrls(formData.linkedin, formData.otherLinks)
console.log("urls", urls)
const processContent = async () => {
setIsLoading(true)
try {
const documentIds: string[] = []
// Step 1: Fetch content from Exa if URLs exist
if (urls.length > 0) {
const response = await fetch("/api/exa/fetch-content", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ urls }),
})
const { results } = await response.json()
console.log("results", results)
// Create documents from Exa results
for (const result of results) {
try {
const docResponse = await $fetch("@post/documents", {
body: {
content: result.text || result.description || "",
containerTags: ["sm_project_default"],
metadata: {
sm_source: "consumer",
exa_url: result.url,
exa_title: result.title,
},
},
})
if (docResponse.data?.id) {
documentIds.push(docResponse.data.id)
}
} catch (error) {
console.warn("Error creating document:", error)
}
}
}
// Step 2: Create document from description if it exists
if (formData.description?.trim()) {
try {
const descDocResponse = await $fetch("@post/documents", {
body: {
content: formData.description,
containerTags: ["sm_project_default"],
metadata: {
sm_source: "consumer",
description_source: "user_input",
},
},
})
if (descDocResponse.data?.id) {
documentIds.push(descDocResponse.data.id)
}
} catch (error) {
console.warn("Error creating description document:", error)
}
}
// Step 3: Poll for memories or show form data
if (documentIds.length > 0) {
await pollForMemories(documentIds)
} else {
// No documents created, show form data or waiting
const formDataMessages = []
if (formData.twitter) {
formDataMessages.push({
message: `Twitter: ${formData.twitter}`,
url: formData.twitter,
title: "Twitter Profile",
description: `Twitter: ${formData.twitter}`,
type: "formData" as const,
})
}
if (formData.linkedin) {
formDataMessages.push({
message: `LinkedIn: ${formData.linkedin}`,
url: formData.linkedin,
title: "LinkedIn Profile",
description: `LinkedIn: ${formData.linkedin}`,
type: "formData" as const,
})
}
if (formData.otherLinks.length > 0) {
formData.otherLinks.forEach((link) => {
formDataMessages.push({
message: `Link: ${link}`,
url: link,
title: "Other Link",
description: `Link: ${link}`,
type: "formData" as const,
})
})
}
const waitingMessage = {
message: "Waiting for your input",
url: "",
title: "",
description: "Waiting for your input",
type: "waiting" as const,
}
setMessages([...formDataMessages, waitingMessage])
}
} catch (error) {
console.warn("Error processing content:", error)
const waitingMessage = {
message: "Waiting for your input",
url: "",
title: "",
description: "Waiting for your input",
type: "waiting" as const,
}
setMessages([waitingMessage])
}
setIsLoading(false)
}
processContent()
}, [formData, pollForMemories])
return (
<AnimatePresence mode="wait">
{!isChatOpen ? (
<motion.div
key="closed"
className={cn(
"absolute top-0 right-0 flex items-start justify-start m-4",
dmSansClassName(),
)}
layoutId="chat-toggle-button"
>
<motion.button
onClick={toggleChat}
className="flex items-center gap-2 rounded-full px-3 py-1.5 text-xs font-medium border border-[#17181A] text-white cursor-pointer"
style={{
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
}}
>
<NovaOrb size={24} className="blur-none! z-10" />
Chat with Nova
</motion.button>
</motion.div>
) : (
<motion.div
key="open"
className={cn(
"w-[450px] h-[calc(100vh-110px)] bg-[#0A0E14] backdrop-blur-md flex flex-col rounded-2xl m-4",
dmSansClassName(),
)}
initial={{ x: "100px", opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: "100px", opacity: 0 }}
transition={{ duration: 0.3, ease: "easeOut", bounce: 0 }}
>
<motion.button
onClick={toggleChat}
className="absolute top-4 right-4 flex items-center gap-2 rounded-full p-2 text-xs text-white cursor-pointer"
style={{
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
}}
layoutId="chat-toggle-button"
>
<PanelRightCloseIcon className="size-4" />
Close chat
</motion.button>
<div className="flex-1 flex flex-col px-4 space-y-3 pb-4 justify-end">
{messages.map((msg, i) => (
<div
key={`message-${i}-${msg.message}`}
className="flex items-start gap-2"
>
{msg.type === "waiting" ? (
<div className="flex items-center gap-2 text-white/50">
<NovaOrb size={30} className="blur-none!" />
<span className="text-sm">{msg.message}</span>
</div>
) : (
<>
<div
className={cn(
"flex flex-col items-center justify-center w-[30px] h-full",
i !== 0 && "",
)}
>
{i === 0 && (
<div className="w-3 h-3 bg-[#293952]/40 rounded-full mb-1" />
)}
<div className="w-px flex-1 bg-[#293952]/40" />
</div>
{msg.type === "memory" && (
<div className="space-y-2 w-full max-h-60 overflow-y-auto scrollbar-thin">
{msg.memories?.map((memory) => (
<div
key={memory.url + memory.title}
className="bg-[#293952]/40 rounded-lg p-2 px-3 space-y-2"
>
{memory.title && (
<h3
className="text-sm font-medium"
style={{
background:
"linear-gradient(90deg, #369BFD 0%, #36FDFD 30%, #36FDB5 100%)",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
backgroundClip: "text",
}}
>
{memory.title}
</h3>
)}
{memory.url && (
<a
href={memory.url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-400 hover:underline break-all"
>
{memory.url}
</a>
)}
{memory.description && (
<p className="text-xs text-white/50 mt-1">
{memory.description}
</p>
)}
</div>
))}
</div>
)}
</>
)}
</div>
))}
{messages.length === 0 && !isLoading && !formData && (
<div className="flex items-center gap-2 text-white/50">
<NovaOrb size={28} className="blur-none!" />
<span className="text-sm">Waiting for your input</span>
</div>
)}
{isLoading && (
<div className="flex items-center gap-2 text-foreground/50">
<NovaOrb size={28} className="blur-none!" />
<span className="text-sm">Fetching your memories...</span>
</div>
)}
</div>
<div className="p-4">
<form
className="flex flex-col gap-3 bg-[#0D121A] rounded-xl p-2 relative"
onSubmit={(e) => {
e.preventDefault()
if (message.trim()) {
handleSend()
}
}}
>
<input
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Chat with your Supermemory"
className="w-full text-white placeholder:text-white/20 rounded-sm outline-none resize-none text-base leading-relaxed bg-transparent px-2 h-10"
/>
<div className="flex justify-end absolute bottom-3 right-2">
<Button
type="submit"
disabled={!message.trim()}
className="text-white/20 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed rounded-xl transition-all"
size="icon"
>
<SendIcon className="size-4" />
</Button>
</div>
</form>
</div>
</motion.div>
)}
</AnimatePresence>
)
}

View file

@ -0,0 +1,47 @@
import { motion } from "motion/react"
import { Logo } from "@ui/assets/Logo"
import { useAuth } from "@lib/auth-context"
import { useEffect, useState } from "react"
import { Avatar, AvatarFallback, AvatarImage } from "@ui/components/avatar"
export function SetupHeader() {
const { user } = useAuth()
const [name, setName] = useState<string>("")
useEffect(() => {
const storedName =
localStorage.getItem("username") || localStorage.getItem("userName") || ""
setName(storedName)
}, [])
const userName = name ? `${name.split(" ")[0]}'s` : "My"
return (
<motion.div
className="flex p-6 justify-between items-center"
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<div className="flex items-center z-10!">
<Logo className="h-7" />
{name && (
<div className="flex flex-col items-start justify-center ml-2">
<p className="text-[#8B8B8B] text-[11px] leading-tight">
{userName}
</p>
<p className="text-white font-bold text-xl leading-none -mt-1">
supermemory
</p>
</div>
)}
</div>
{user && (
<Avatar className="border border-border h-8 w-8 md:h-10 md:w-10 z-10!">
<AvatarImage src={user?.image ?? ""} />
<AvatarFallback>{user?.name?.charAt(0)}</AvatarFallback>
</Avatar>
)}
</motion.div>
)
}

View file

@ -0,0 +1,181 @@
"use client"
import { useState } from "react"
import { Button } from "@ui/components/button"
import { MCPDetailView } from "@/components/new/mcp-modal/mcp-detail-view"
import { XBookmarksDetailView } from "@/components/x-bookmarks-detail-view"
import { useRouter } from "next/navigation"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/utils/fonts"
import { useOnboardingStorage } from "@hooks/use-onboarding-storage"
const integrationCards = [
{
title: "Capture",
description: "Add the Chrome extension for one-click saves",
icon: (
<div className="rounded-full flex items-center justify-center">
<img
src="/onboarding/chrome.png"
alt="Chrome"
className="w-20 h-auto"
/>
</div>
),
},
{
title: "Connect to AI",
description: "Set up once and use your memory in Cursor, Claude, etc",
icon: (
<div className="rounded flex items-center justify-center">
<img src="/onboarding/mcp.png" alt="MCP" className="size-28 h-auto" />
</div>
),
},
{
title: "Connect",
description: "Link Notion, Google Drive, or OneDrive to import your docs",
icon: (
<div className="rounded flex items-center justify-center">
<img
src="/onboarding/connectors.png"
alt="Connectors"
className="w-20 h-auto"
/>
</div>
),
},
{
title: "Import",
description:
"Bring in X/Twitter bookmarks, and turn them into useful memories",
icon: (
<div className="rounded flex items-center justify-center">
<img src="/onboarding/x.png" alt="X" className="size-14" />
</div>
),
},
]
export function IntegrationsStep() {
const router = useRouter()
const [selectedCard, setSelectedCard] = useState<string | null>(null)
const { markOnboardingCompleted } = useOnboardingStorage()
const handleContinue = () => {
markOnboardingCompleted()
router.push("/new")
}
if (selectedCard === "Connect to AI") {
return <MCPDetailView onBack={() => setSelectedCard(null)} />
}
if (selectedCard === "Import") {
return <XBookmarksDetailView onBack={() => setSelectedCard(null)} />
}
return (
<div className="flex flex-col items-center justify-center h-full p-8">
<div className="text-center mb-6 flex flex-col items-center justify-center space-y-2">
<h1 className="text-white text-[32px] font-medium">
Build your personal memory
</h1>
<p
className={cn(
"text-white text-sm opacity-60 max-w-xs",
dmSansClassName(),
)}
>
Your supermemory comes alive when you <br /> capture and connect
what's important
</p>
</div>
<div className="grid grid-cols-2 gap-3 max-w-lg w-full mb-12">
{integrationCards.map((card) => {
const isClickable =
card.title === "Connect to AI" ||
card.title === "Capture" ||
card.title === "Import"
if (isClickable) {
return (
<button
key={card.title}
type="button"
className={cn(
"bg-[#080B0F] relative rounded-lg p-3 hover:border-[#3374FF] hover:border-[0.1px] transition-colors duration-300 border-[0.1px] border-[#0D121A] cursor-pointer text-left w-full hover:bg-[url('/onboarding/bg-gradient-1.png')] hover:bg-[length:175%_auto] hover:bg-[center_top_2rem] hover:bg-no-repeat",
"hover:border-b-0 border-b-0",
)}
onClick={() => {
if (card.title === "Capture") {
window.open(
"https://chromewebstore.google.com/detail/supermemory/afpgkkipfdpeaflnpoaffkcankadgjfc",
"_blank",
)
} else {
setSelectedCard(card.title)
}
}}
>
<div className="flex-1 mt-10">
<h3 className="text-white text-sm font-medium">
{card.title}
</h3>
<p
className={cn(
"text-[#8B8B8B] text-xs leading-relaxed",
dmSansClassName(),
)}
>
{card.description}
</p>
</div>
<div className="absolute top-0 right-0">{card.icon}</div>
</button>
)
}
return (
<div
key={card.title}
className={cn(
"bg-[#080B0F] relative rounded-lg p-3 hover:border-[#3374FF] hover:border-[0.1px] transition-colors duration-300 border-[0.1px] border-[#0D121A] hover:bg-[url('/onboarding/bg-gradient-1.png')] hover:bg-[length:175%_auto] hover:bg-[center_top_2rem] hover:bg-no-repeat",
"hover:border-b-0 border-b-0",
)}
>
<div className="flex-1 mt-10">
<h3 className="text-white text-sm font-medium">{card.title}</h3>
<p
className={cn(
"text-[#8B8B8B] text-xs leading-relaxed",
dmSansClassName(),
)}
>
{card.description}
</p>
</div>
<div className="absolute top-0 right-0">{card.icon}</div>
</div>
)
})}
</div>
<div className="flex justify-between w-full max-w-4xl">
<Button
variant="link"
className="text-white hover:text-gray-300 hover:no-underline cursor-pointer"
onClick={() => router.push("/new/onboarding?flow=setup&step=relatable")}
>
← Back
</Button>
<Button
variant="link"
className="text-white hover:text-gray-300 hover:no-underline cursor-pointer"
onClick={handleContinue}
>
Continue →
</Button>
</div>
</div>
)
}

View file

@ -0,0 +1,159 @@
"use client"
import { useState } from "react"
import { motion, AnimatePresence } from "motion/react"
import { Button } from "@ui/components/button"
import { useRouter } from "next/navigation"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/utils/fonts"
const relatableOptions = [
{
emoji: "😔",
text: "I always forget what I save in my twitter bookmarks",
},
{
emoji: "😭",
text: "Going through e-books manually is so tedious",
},
{
emoji: "🥲",
text: "I always have to feed every AI app with my data",
},
{
emoji: "😵‍💫",
text: "Referring meeting notes makes my AI chat hallucinate",
},
{
emoji: "🫤",
text: "I save nothing on my browser, it's just useless",
},
]
export function RelatableQuestion() {
const router = useRouter()
const [selectedOptions, setSelectedOptions] = useState<number[]>([])
const handleContinueOrSkip = () => {
router.push("/new/onboarding?flow=setup&step=integrations")
}
return (
<motion.div
className="flex flex-col items-center justify-center h-full"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.6 }}
>
<motion.h1
className="text-white text-[32px] font-medium mb-6 text-center"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.2 }}
>
Which of these sound most relatable?
</motion.h1>
<div
className={cn(
"flex flex-wrap justify-center gap-4 max-w-3xl",
dmSansClassName(),
)}
>
{relatableOptions.map((option, index) => (
<div
key={option.text}
className={cn(
"rounded-lg max-w-[140px] min-h-[159px] transition-all duration-300",
selectedOptions.includes(index)
? "p-px bg-linear-to-b from-[#3374FF] to-[#1A63FF00]"
: "p-0 border border-[#0D121A] hover:border-[#4C608B66]",
)}
>
<button
className={`
group relative w-full h-full rounded-lg p-2 cursor-pointer transition-all duration-300 overflow-hidden
bg-[#080B0F] hover:bg-no-repeat
`}
onClick={() => {
setSelectedOptions((prev) =>
prev.includes(index)
? prev.filter((i) => i !== index)
: [...prev, index],
)
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
setSelectedOptions((prev) =>
prev.includes(index)
? prev.filter((i) => i !== index)
: [...prev, index],
)
}
}}
type="button"
>
<AnimatePresence>
{selectedOptions.includes(index) && (
<motion.div
className="absolute inset-0 bg-[url('/onboarding/bg-gradient-1.png')] bg-size-[550%_auto] bg-top bg-no-repeat"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
/>
)}
</AnimatePresence>
<div className="relative flex flex-col items-start justify-between h-full">
<span
className={`text-2xl ${
selectedOptions.includes(index)
? "opacity-100"
: "opacity-70 group-hover:opacity-100"
}`}
>
{option.emoji}
</span>
<p
className={`text-white text-sm leading-[135%] align-bottom text-left transition-opacity duration-300 ${
selectedOptions.includes(index)
? "opacity-100"
: "opacity-50 group-hover:opacity-100"
}`}
>
{option.text}
</p>
</div>
</button>
</div>
))}
</div>
<div className="flex gap-4 my-8">
<div key={selectedOptions.length === 0 ? "skip" : "continue"}>
<Button
className={cn(
"font-medium text-white hover:no-underline cursor-pointer",
selectedOptions.length !== 0 ? "rounded-xl" : "",
)}
variant={selectedOptions.length !== 0 ? "onboarding" : "link"}
size="lg"
onClick={handleContinueOrSkip}
style={
selectedOptions.length !== 0
? {
background:
"linear-gradient(180deg, #0D121A -26.14%, #000 100%)",
}
: undefined
}
>
{selectedOptions.length === 0
? "Skip for now →"
: "Remember this →"}
</Button>
</div>
</div>
</motion.div>
)
}

View file

@ -0,0 +1,45 @@
import { dmSansClassName } from "@/utils/fonts"
import { cn } from "@lib/utils"
import { Button } from "@ui/components/button"
import { motion } from "motion/react"
import { useRouter } from "next/navigation"
export function ContinueStep() {
const router = useRouter()
const handleContinue = () => {
router.push("/new/onboarding?flow=welcome&step=features")
}
return (
<motion.div
className="text-center"
initial={{ opacity: 0, y: 0, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 0, scale: 0.9 }}
transition={{ duration: 1, ease: "easeOut" }}
layout
>
<p
className={cn(
"text-[#8A8A8A] text-sm mb-6 max-w-sm",
dmSansClassName(),
)}
>
I'm built with Supermemory's super fast memory API,
<br /> so you never have to worry about forgetting <br /> what matters
across your AI apps.
</p>
<Button
variant="onboarding"
onClick={handleContinue}
style={{
background: "linear-gradient(180deg, #0D121A -26.14%, #000 100%)",
width: "147px",
}}
>
Continue →
</Button>
</motion.div>
)
}

View file

@ -0,0 +1,98 @@
import { motion } from "motion/react"
import { Button } from "@ui/components/button"
import { useRouter } from "next/navigation"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/utils/fonts"
export function FeaturesStep() {
const router = useRouter()
const handleContinue = () => {
router.push("/new/onboarding?flow=welcome&step=memories")
}
return (
<motion.div
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: "easeOut", delay: 0.3 }}
className="text-center max-w-88 space-y-6"
layout
>
<h2 className="text-white text-[32px] font-medium leading-[110%]">
What I can do for you
</h2>
<div className={cn("space-y-4 mb-[24px] mx-4", dmSansClassName())}>
<div className="flex items-start space-x-2">
<div className="w-14 h-14 rounded-lg flex items-center justify-center shrink-0">
<img
src="/onboarding/human-brain.png"
alt="Brain icon"
className="w-14 h-14"
/>
</div>
<div className="text-left">
<p className="text-white font-light">Remember every context</p>
<p className="text-[#8A8A8A] text-[14px]">
I keep track of what you've saved and shared with your
supermemory.
</p>
</div>
</div>
<div className="flex items-start space-x-2">
<div className="w-14 h-14 rounded-lg flex items-center justify-center shrink-0">
<img
src="/onboarding/search.png"
alt="Search icon"
className="w-14 h-14"
/>
</div>
<div className="text-left">
<p className="text-white font-light">Find when you need it</p>
<p className="text-[#8A8A8A] text-[14px]">
I surface the right memories inside <br /> your supermemory,
superfast.
</p>
</div>
</div>
<div className="flex items-start space-x-2">
<div className="w-14 h-14 rounded-lg flex items-center justify-center shrink-0">
<img
src="/onboarding/plant.png"
alt="Growth icon"
className="w-14 h-14"
/>
</div>
<div className="text-left">
<p className="text-white font-light">Grow with your supermemory</p>
<p className="text-[#8A8A8A] text-[14px]">
I learn and personalize over time, so every interaction feels
natural.
</p>
</div>
</div>
</div>
<motion.div
animate={{
opacity: 1,
y: 0,
}}
transition={{ duration: 1, ease: "easeOut", delay: 1 }}
initial={{ opacity: 0, y: 10 }}
>
<Button
variant="onboarding"
style={{
background: "linear-gradient(180deg, #0D121A -26.14%, #000 100%)",
}}
onClick={handleContinue}
>
Add memories →
</Button>
</motion.div>
</motion.div>
)
}

View file

@ -0,0 +1,23 @@
import { motion } from "motion/react"
interface GreetingStepProps {
name: string
}
export function GreetingStep({ name }: GreetingStepProps) {
const userName = name ? `${name.split(" ")[0]}` : ""
return (
<motion.div
className="text-center"
initial={{ opacity: 0, y: 0 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 0 }}
transition={{ duration: 1, ease: "easeOut" }}
layout
>
<h2 className="text-white text-[32px] font-medium mb-2">
Hi {userName}, I'm Nova
</h2>
</motion.div>
)
}

View file

@ -0,0 +1,96 @@
import { motion } from "motion/react"
import { LabeledInput } from "@ui/input/labeled-input"
import { Button } from "@ui/components/button"
interface InputStepProps {
name: string
setName: (name: string) => void
handleSubmit: () => void
isSubmitting: boolean
}
export function InputStep({
name,
setName,
handleSubmit,
isSubmitting,
}: InputStepProps) {
return (
<motion.div
className="text-center min-w-[250px] flex flex-col"
style={{ gap: "24px" }}
initial={{
opacity: 0,
y: 10,
}}
animate={{
opacity: 1,
y: 0,
}}
exit={{
opacity: 0,
y: -10,
transition: {
duration: 0.5,
ease: "easeOut",
bounce: 0,
},
}}
transition={{
duration: 0.8,
ease: "easeOut",
delay: 1,
}}
layout
>
<h2 className="text-white text-[32px] font-medium leading-[110%]">
What should I call you?
</h2>
<div className="flex items-center w-full relative">
<LabeledInput
inputType="text"
inputPlaceholder="your name"
className="w-full flex-1"
inputProps={{
defaultValue: name,
onKeyDown: (e) => {
if (e.key === "Enter") {
handleSubmit()
}
},
className: "!text-white placeholder:!text-[#525966] !h-[40px] pl-4",
}}
onChange={(e) => setName((e.target as HTMLInputElement).value)}
style={{
background:
"linear-gradient(0deg, rgba(91, 126, 245, 0.04) 0%, rgba(91, 126, 245, 0.04) 100%)",
}}
/>
<Button
className={`rounded-[8px] w-8 h-8 p-2 absolute right-1 border-[0.5px] border-[#161F2C] hover:cursor-pointer hover:scale-[0.95] active:scale-[0.95] transition-transform ${
isSubmitting ? "scale-[0.90]" : ""
}`}
size="icon"
onClick={handleSubmit}
style={{
background: "linear-gradient(180deg, #0D121A -26.14%, #000 100%)",
}}
>
<svg
width="12"
height="9"
viewBox="0 0 12 9"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<title>Next</title>
<path
d="M8.05099 9.60156L6.93234 8.49987L9.00014 6.44902L9.62726 6.04224L9.54251 5.788L8.79675 5.90665H0.0170898V4.31343H8.79675L9.54251 4.43207L9.62726 4.17783L9.00014 3.77105L6.93234 1.72021L8.05099 0.601562L11.9832 4.53377V5.68631L8.05099 9.60156Z"
fill="#FAFAFA"
/>
</svg>
</Button>
</div>
</motion.div>
)
}

View file

@ -0,0 +1,299 @@
import { motion } from "motion/react"
import { Button } from "@ui/components/button"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/utils/fonts"
interface MemoriesStepProps {
onSubmit: (data: {
twitter: string
linkedin: string
description: string
otherLinks: string[]
}) => void
}
type ValidationError = {
twitter: string | null
linkedin: string | null
}
export function MemoriesStep({ onSubmit }: MemoriesStepProps) {
const router = useRouter()
const [otherLinks, setOtherLinks] = useState([""])
const [twitterHandle, setTwitterHandle] = useState("")
const [linkedinProfile, setLinkedinProfile] = useState("")
const [description, setDescription] = useState("")
const [isSubmitting] = useState(false)
const [errors, setErrors] = useState<ValidationError>({
twitter: null,
linkedin: null,
})
const addOtherLink = () => {
if (otherLinks.length < 3) {
setOtherLinks([...otherLinks, ""])
}
}
const updateOtherLink = (index: number, value: string) => {
const updated = [...otherLinks]
updated[index] = value
setOtherLinks(updated)
}
const validateTwitterLink = (value: string): string | null => {
if (!value.trim()) return null
const normalized = value.trim().toLowerCase()
const isXDomain =
normalized.includes("x.com") || normalized.includes("twitter.com")
if (!isXDomain) {
return "share your X profile link"
}
// Check if it's a profile link (not a status/tweet link)
const profilePattern =
/^(https?:\/\/)?(www\.)?(x\.com|twitter\.com)\/[^\/]+$/
const statusPattern = /\/status\//i
if (statusPattern.test(normalized) || !profilePattern.test(normalized)) {
return "share your X profile link"
}
// Note: 404 validation would require a backend API endpoint
// Format validation is handled above
return null
}
const validateLinkedInLink = (value: string): string | null => {
if (!value.trim()) return null
const normalized = value.trim().toLowerCase()
const isLinkedInDomain = normalized.includes("linkedin.com")
if (!isLinkedInDomain) {
return "share your Linkedin profile link"
}
// Check if it's a profile link (should have /in/ or /pub/)
const profilePattern =
/^(https?:\/\/)?(www\.)?linkedin\.com\/(in|pub)\/[^\/]+/
if (!profilePattern.test(normalized)) {
return "share your Linkedin profile link"
}
// Note: 404 validation would require a backend API endpoint
// Format validation is handled above
return null
}
const handleTwitterChange = (value: string) => {
setTwitterHandle(value)
const error = validateTwitterLink(value)
setErrors((prev) => ({ ...prev, twitter: error }))
}
const handleLinkedInChange = (value: string) => {
setLinkedinProfile(value)
const error = validateLinkedInLink(value)
setErrors((prev) => ({ ...prev, linkedin: error }))
}
return (
<motion.div
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: "easeOut", delay: 0.3 }}
className="text-center w-full "
>
<h2 className="text-white text-[32px] font-medium mb-4 mt-[-36px]">
Let's add your memories
</h2>
<div className="space-y-4 max-w-[329px] mx-auto overflow-visible gap-4">
<div className="text-left gap-[6px] flex flex-col" id="x-twitter-field">
<label
htmlFor="twitter-handle"
className="text-white text-sm font-medium block pl-2"
>
X/Twitter
</label>
<div className="relative flex items-center">
<input
id="twitter-handle"
type="text"
placeholder="x.com/yourhandle"
value={twitterHandle}
onChange={(e) => handleTwitterChange(e.target.value)}
onBlur={() => {
if (twitterHandle.trim()) {
const error = validateTwitterLink(twitterHandle)
setErrors((prev) => ({ ...prev, twitter: error }))
}
}}
className={`w-full px-4 py-2 bg-[#070E1B] border rounded-xl text-white placeholder-onboarding focus:outline-none transition-colors h-[40px] ${
errors.twitter
? "border-[#52596633] bg-[#290F0A]"
: "border-onboarding/20"
}`}
/>
{errors.twitter && (
<div className="absolute left-full ml-3">
<div
className={cn(
"relative shrink-0 px-3 py-2 bg-[#290F0A] text-[#C73B1B] rounded-xl",
dmSansClassName(),
)}
>
<div className="absolute left-0.5 top-1/2 -translate-x-full -translate-y-1/2 w-0 h-0 border-t-[6px] border-b-[6px] border-r-8 border-t-transparent border-b-transparent border-[#290F0A]" />
<p className="text-xs whitespace-nowrap">{errors.twitter}</p>
</div>
</div>
)}
</div>
</div>
<div className="text-left gap-[6px] flex flex-col" id="linkedin-field">
<label
htmlFor="linkedin-profile"
className="text-white text-sm font-medium block pl-2"
>
LinkedIn
</label>
<div className="relative flex items-center">
<input
id="linkedin-profile"
type="text"
placeholder="linkedin.com/in/yourname"
value={linkedinProfile}
onChange={(e) => handleLinkedInChange(e.target.value)}
onBlur={() => {
if (linkedinProfile.trim()) {
const error = validateLinkedInLink(linkedinProfile)
setErrors((prev) => ({ ...prev, linkedin: error }))
}
}}
className={`w-full px-4 py-2 bg-[#070E1B] border rounded-xl text-white placeholder-onboarding focus:outline-none transition-colors h-[40px] ${
errors.linkedin
? "border-[#52596633] bg-[#290F0A]"
: "border-onboarding/20"
}`}
/>
{errors.linkedin && (
<div className="absolute left-full ml-3">
<div
className={cn(
"relative shrink-0 px-3 py-2 bg-[#290F0A] text-[#C73B1B] rounded-xl",
dmSansClassName(),
)}
>
<div className="absolute left-0.5 top-1/2 -translate-x-full -translate-y-1/2 w-0 h-0 border-t-[6px] border-b-[6px] border-r-8 border-t-transparent border-b-transparent border-[#290F0A]" />
<p className="text-xs whitespace-nowrap">{errors.linkedin}</p>
</div>
</div>
)}
</div>
</div>
<div
className="text-left gap-[6px] flex flex-col"
id="other-links-field"
>
<div className="flex items-center justify-between">
<label
htmlFor="other-links"
className="text-white text-sm font-medium pl-2"
>
Other links
</label>
<span className="text-onboarding text-[10px]">Upto 3</span>
</div>
<div className="flex flex-col gap-1.5">
{otherLinks.map((link, index) => (
<div
key={`other-link-${index}`}
className="flex items-center relative"
>
<input
id={`other-links-${index}`}
type="text"
placeholder="Add your website, GitHub, Notion..."
value={link}
onChange={(e) => updateOtherLink(index, e.target.value)}
className="flex-1 px-4 py-2 bg-[#070E1B] border border-onboarding/20 rounded-xl text-white placeholder-onboarding focus:outline-none focus:border-[#4A4A4A] transition-colors h-[40px]"
/>
{index === otherLinks.length - 1 && otherLinks.length < 3 && (
<button
type="button"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
addOtherLink()
}}
className="size-8 m-1 absolute right-0 top-0 bg-black border border-[#161F2C] rounded-lg flex items-center justify-center text-white hover:bg-[#161F2C] transition-colors text-xl"
>
+
</button>
)}
</div>
))}
</div>
</div>
<div
className="text-left gap-[6px] flex flex-col"
id="description-field"
>
<label
htmlFor="description"
className="text-white text-sm font-medium block pl-2"
>
What do you do? What do you like?
</label>
<textarea
id="description"
placeholder="Tell me the basics in your words. A few lines about your work, interests, etc."
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
className="w-full px-4 py-2 bg-[#070E1B] border border-onboarding/20 rounded-xl text-white placeholder-onboarding focus:outline-none focus:border-[#4A4A4A] transition-colors min-h-16"
/>
</div>
</div>
<motion.div
animate={{
opacity: 1,
y: 0,
}}
transition={{ duration: 1, ease: "easeOut", delay: 1 }}
initial={{ opacity: 0, y: 10 }}
className="mt-[24px] pb-30"
>
<Button
variant="onboarding"
disabled={isSubmitting}
style={{
background: "linear-gradient(180deg, #0D121A -26.14%, #000 100%)",
}}
onClick={() => {
const formData = {
twitter: twitterHandle,
linkedin: linkedinProfile,
description: description,
otherLinks: otherLinks.filter((l) => l.trim()),
}
onSubmit(formData)
router.push("/new/onboarding?flow=setup&step=relatable")
}}
>
{isSubmitting ? "Fetching..." : "Remember this →"}
</Button>
</motion.div>
</motion.div>
)
}

View file

@ -0,0 +1,16 @@
import { motion } from "motion/react"
export function WelcomeStep() {
return (
<motion.div
className="text-center"
initial={{ opacity: 0, y: 0 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 0 }}
transition={{ duration: 1, ease: "easeOut" }}
layout
>
<h2 className="text-white text-[32px] font-medium mb-2">Welcome to...</h2>
</motion.div>
)
}

57
apps/web/app/new/page.tsx Normal file
View file

@ -0,0 +1,57 @@
"use client"
import { useState } from "react"
import { Header } from "@/components/new/header"
import { ChatSidebar } from "@/components/new/chat"
import { MemoriesGrid } from "@/components/new/memories-grid"
import { AnimatedGradientBackground } from "@/components/new/animated-gradient-background"
import { AddDocumentModal } from "@/components/new/add-document"
import { MCPModal } from "@/components/new/mcp-modal"
import { HotkeysProvider } from "react-hotkeys-hook"
import { useHotkeys } from "react-hotkeys-hook"
import { AnimatePresence } from "framer-motion"
export default function NewPage() {
const [isAddDocumentOpen, setIsAddDocumentOpen] = useState(false)
const [isMCPModalOpen, setIsMCPModalOpen] = useState(false)
useHotkeys("c", () => setIsAddDocumentOpen(true))
const [isChatOpen, setIsChatOpen] = useState(true)
return (
<HotkeysProvider>
<div className="h-screen overflow-hidden bg-black">
<AnimatedGradientBackground
topPosition="15%"
animateFromBottom={false}
/>
<Header
onAddMemory={() => setIsAddDocumentOpen(true)}
onOpenMCP={() => setIsMCPModalOpen(true)}
/>
<main className="relative">
<div key={`main-container-${isChatOpen}`} className="relative z-10">
<div className="flex flex-row h-[calc(100vh-90px)] relative">
<div className="flex-1 flex flex-col justify-start p-6 pr-0">
<MemoriesGrid isChatOpen={isChatOpen} />
</div>
<AnimatePresence mode="popLayout">
<ChatSidebar
isChatOpen={isChatOpen}
setIsChatOpen={setIsChatOpen}
/>
</AnimatePresence>
</div>
</div>
</main>
<AddDocumentModal
isOpen={isAddDocumentOpen}
onClose={() => setIsAddDocumentOpen(false)}
/>
<MCPModal
isOpen={isMCPModalOpen}
onClose={() => setIsMCPModalOpen(false)}
/>
</div>
</HotkeysProvider>
)
}

View file

@ -0,0 +1,243 @@
"use client"
import { Logo } from "@ui/assets/Logo"
import { Avatar, AvatarFallback, AvatarImage } from "@ui/components/avatar"
import { useAuth } from "@lib/auth-context"
import { motion } from "motion/react"
import NovaOrb from "@/components/nova/nova-orb"
import { useState, useEffect, useRef } from "react"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/utils/fonts"
import Account from "@/components/new/settings/account"
import Integrations from "@/components/new/settings/integrations"
import ConnectionsMCP from "@/components/new/settings/connections-mcp"
import Support from "@/components/new/settings/support"
import { useRouter } from "next/navigation"
const TABS = ["account", "integrations", "connections", "support"] as const
type SettingsTab = (typeof TABS)[number]
type NavItem = {
id: SettingsTab
label: string
description: string
icon: React.ReactNode
}
const NAV_ITEMS: NavItem[] = [
{
id: "account",
label: "Account & Billing",
description: "Manage your profile, plan, usage and payments",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
),
},
{
id: "integrations",
label: "Integrations",
description: "Save, sync and search memories across tools",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="3" />
<path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83" />
</svg>
),
},
{
id: "connections",
label: "Connections & MCP",
description: "Sync with Google Drive, Notion, OneDrive and MCP client",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z" />
</svg>
),
},
{
id: "support",
label: "Support & Help",
description: "Find answers or share feedback. We're here to help.",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10" />
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
<path d="M12 17h.01" />
</svg>
),
},
]
function parseHashToTab(hash: string): SettingsTab {
const cleaned = hash.replace("#", "").toLowerCase()
return TABS.includes(cleaned as SettingsTab)
? (cleaned as SettingsTab)
: "account"
}
export function UserSupermemory({ name }: { name: string }) {
return (
<motion.div
className="absolute inset-0 top-[-40px] flex items-center justify-center z-10"
initial={{ opacity: 0, y: 0 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 0 }}
transition={{ duration: 1, ease: "easeOut" }}
>
<Logo className="h-7 text-white" />
<div className="flex flex-col items-start justify-center ml-4 space-y-1">
<p className="text-white text-[15px] font-medium leading-none">
{name.split(" ")[0]}'s
</p>
<p className="text-white font-bold text-xl leading-none -mt-2">
supermemory
</p>
</div>
</motion.div>
)
}
export default function SettingsPage() {
const { user } = useAuth()
const [activeTab, setActiveTab] = useState<SettingsTab>("account")
const hasInitialized = useRef(false)
const router = useRouter()
useEffect(() => {
if (hasInitialized.current) return
hasInitialized.current = true
const hash = window.location.hash
const tab = parseHashToTab(hash)
setActiveTab(tab)
// If no hash or invalid hash, push #account
if (!hash || !TABS.includes(hash.replace("#", "") as SettingsTab)) {
window.history.pushState(null, "", "#account")
}
}, [])
useEffect(() => {
const handleHashChange = () => {
const tab = parseHashToTab(window.location.hash)
setActiveTab(tab)
}
window.addEventListener("hashchange", handleHashChange)
return () => window.removeEventListener("hashchange", handleHashChange)
}, [])
return (
<div className="h-screen flex flex-col overflow-hidden">
<header className="flex justify-between items-center px-6 py-3 shrink-0">
<button type="button" onClick={() => router.push("/new")} className="cursor-pointer">
<Logo className="h-7" />
</button>
<div className="flex items-center gap-2">
{user && (
<Avatar className="border border-border h-8 w-8 md:h-10 md:w-10">
<AvatarImage src={user?.image ?? ""} />
<AvatarFallback>{user?.name?.charAt(0)}</AvatarFallback>
</Avatar>
)}
</div>
</header>
<main className="max-w-2xl mx-auto space-x-12 flex justify-center pt-4 flex-1 min-h-0">
<div className="min-w-xs">
<motion.div
animate={{
scale: 1,
padding: 48,
paddingTop: 0,
}}
transition={{
duration: 0.8,
ease: "easeOut",
delay: 0.2,
}}
className="relative flex items-center justify-center"
>
<NovaOrb size={175} className="blur-[3px]!" />
<UserSupermemory name={user?.name ?? ""} />
</motion.div>
<nav className={cn("flex flex-col gap-2", dmSansClassName())}>
{NAV_ITEMS.map((item) => (
<button
key={item.id}
type="button"
onClick={() => {
window.location.hash = item.id
setActiveTab(item.id)
}}
className={`text-left p-4 rounded-xl transition-colors flex items-start gap-3 ${
activeTab === item.id
? "bg-[#14161A] text-white shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]"
: "text-white/60 hover:text-white hover:bg-[#14161A] hover:shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]"
}`}
>
<span className="mt-0.5 shrink-0">{item.icon}</span>
<div className="flex flex-col gap-0.5">
<span className="font-medium">{item.label}</span>
<span className="text-sm text-white/50">
{item.description}
</span>
</div>
</button>
))}
</nav>
</div>
<div className="flex flex-col gap-4 overflow-y-auto min-w-2xl [scrollbar-gutter:stable] pr-[17px]">
{activeTab === "account" && <Account />}
{activeTab === "integrations" && <Integrations />}
{activeTab === "connections" && <ConnectionsMCP />}
{activeTab === "support" && <Support />}
</div>
</main>
</div>
)
}

View file

@ -0,0 +1,228 @@
"use client"
import {
motion,
useMotionValue,
useTransform,
animate,
useReducedMotion,
} from "motion/react"
import { useEffect, useMemo } from "react"
import * as flubber from "flubber"
type ChatLoaderProps = {
size?: number
colorClassName?: string
label?: string
className?: string
}
const LEFT_PATHS = [
"M12.6984 9.02793V3.52344H10.6523V9.49591C10.6523 10.1302 10.9028 10.7395 11.3479 11.1883L16.5188 16.4032L17.9655 14.9441L14.1463 11.0926H19.0324V9.02914L12.6984 9.02793Z", // 0
"M12.6984 9.02793V3.52344H10.6523V9.49591C10.6523 10.1302 10.9028 10.7395 11.3479 11.1883L16.5188 16.4032L17.9655 14.9441L14.1463 11.0926H14.149L12.699 9.02914L12.6984 9.02793Z", // 1
"M12.6985 9.02793V3.52344H10.6524V9.49591C10.6524 10.1302 10.6516 10.7381 10.6532 11.0926L10.6524 16.4075H12.6985L12.6991 11.0926V9.02914L12.6985 9.02793Z", // 2
"M14.5653 7.14453V7.1485H10.6528V8.0394C10.6528 9.25237 10.6512 10.4147 10.6542 11.0925L10.6528 11.0887H14.5653L14.5664 11.0925V7.14684L14.5653 7.14453Z", // 3
"M19.0304 8.51562V8.51963H15.0776V9.41971C15.0776 10.6452 15.076 11.8194 15.0791 12.5043L15.0776 12.5004H19.0304L19.0315 12.5043V8.51796L19.0304 8.51562Z", // 4
"M19.0304 8.51562V8.51963H15.0776V9.41971C15.0776 10.6452 15.076 11.8194 15.0791 12.5043L15.0776 12.5004H19.0304L19.0315 12.5043V8.51796L19.0304 8.51562Z", // 5
"M19.0304 8.51562V8.51963H15.0776V9.41971C15.0776 10.6452 15.076 11.8194 15.0791 12.5043L15.0776 12.5004H19.0304L19.0315 12.5043V8.51796L19.0304 8.51562Z", // 6
"M19.0304 6.51562V6.51963H15.0776V7.41971C15.0776 8.64517 15.076 9.81944 15.0791 10.5043L15.0776 10.5004H19.0304L19.0315 10.5043V6.51796L19.0304 6.51562Z", // 7
"M19.0304 8.51562V8.51963H15.0776V9.41971C15.0776 10.6452 15.076 11.8194 15.0791 12.5043L15.0776 12.5004H19.0304L19.0315 12.5043V8.51796L19.0304 8.51562Z", // 8
"M19.0304 8.51562V8.51963H15.0776V9.41971C15.0776 10.6452 15.076 11.8194 15.0791 12.5043L15.0776 12.5004H19.0304L19.0315 12.5043V8.51796L19.0304 8.51562Z", // 9
"M19.0304 6.51562V6.51963H15.0776V7.41971C15.0776 8.64517 15.076 9.81944 15.0791 10.5043L15.0776 10.5004H19.0304L19.0315 10.5043V6.51796L19.0304 6.51562Z", // 10
"M19.0304 8.51562V8.51963H15.0776V9.41971C15.0776 10.6452 15.076 11.8194 15.0791 12.5043L15.0776 12.5004H19.0304L19.0315 12.5043V8.51796L19.0304 8.51562Z", // 11
"M19.0304 8.51562V8.51963H15.0776V9.41971C15.0776 10.6452 15.076 11.8194 15.0791 12.5043L15.0776 12.5004H19.0304L19.0315 12.5043V8.51796L19.0304 8.51562Z", // 12
"M19.0304 6.51562V6.51963H15.0776V7.41971C15.0776 8.64517 15.076 9.81944 15.0791 10.5043L15.0776 10.5004H19.0304L19.0315 10.5043V6.51796L19.0304 6.51562Z", // 13
"M19.0304 8.51562V8.51963H15.0776V9.41971C15.0776 10.6452 15.076 11.8194 15.0791 12.5043L15.0776 12.5004H19.0304L19.0315 12.5043V8.51796L19.0304 8.51562Z", // 14
"M14.5653 7.14453V7.1485H10.6528V8.0394C10.6528 9.25237 10.6512 10.4147 10.6542 11.0925L10.6528 11.0887H14.5653L14.5664 11.0925V7.14684L14.5653 7.14453Z", // 15
"M12.6985 9.02793V3.52344H10.6524V9.49591C10.6524 10.1302 10.6516 10.7381 10.6532 11.0926L10.6524 16.4075H12.6985L12.6991 11.0926V9.02914L12.6985 9.02793Z", // 16
"M12.6984 9.02793V3.52344H10.6523V9.49591C10.6523 10.1302 10.9028 10.7395 11.3479 11.1883L16.5188 16.4032L17.9655 14.9441L14.1463 11.0926H14.149L12.699 9.02914L12.6984 9.02793Z", // 17
"M12.6984 9.02793V3.52344H10.6523V9.49591C10.6523 10.1302 10.9028 10.7395 11.3479 11.1883L16.5188 16.4032L17.9655 14.9441L14.1463 11.0926H19.0324V9.02914L12.6984 9.02793Z", // 18
]
const MIDDLE_PATHS = [
"M6.60156 9.46875L6.60171 11.5326L6.60231 11.5286H8.64841V11.0646C8.64841 10.4302 8.64841 10.1053 8.64841 9.46911L6.60156 9.46875Z", // 0
"M6.60156 9.46875L6.60171 11.5326L6.60231 11.5286H8.64841V11.0646C8.64841 10.4302 8.64841 10.1053 8.64841 9.46911L6.60156 9.46875Z", // 1
"M6.60156 9.46875L6.60171 11.5326L6.60231 11.5286H8.64841V11.0646C8.64841 10.4302 8.64841 10.1053 8.64841 9.46911L6.60156 9.46875Z", // 2
"M6.60156 9.46875L6.60171 11.5326L6.60231 11.5286H8.64841V11.0646C8.64841 10.4302 8.64841 10.1053 8.64841 9.46911L6.60156 9.46875Z", // 3
"M1.96875 8.50391L1.96904 12.4909L1.9702 12.4833H5.92302V11.5868C5.92302 10.3613 5.92302 9.73358 5.92302 8.50459L1.96875 8.50391Z", // 4
"M1.96875 6.50391L1.96904 10.4909L1.9702 10.4833H5.92302V9.58685C5.92302 8.3613 5.92302 7.73358 5.92302 6.50459L1.96875 6.50391Z", // 5
"M1.96875 8.50391L1.96904 12.4909L1.9702 12.4833H5.92302V11.5868C5.92302 10.3613 5.92302 9.73358 5.92302 8.50459L1.96875 8.50391Z", // 6
"M1.96875 8.50391L1.96904 12.4909L1.9702 12.4833H5.92302V11.5868C5.92302 10.3613 5.92302 9.73358 5.92302 8.50459L1.96875 8.50391Z", // 7
"M1.96875 6.50391L1.96904 10.4909L1.9702 10.4833H5.92302V9.58685C5.92302 8.3613 5.92302 7.73358 5.92302 6.50459L1.96875 6.50391Z", // 8
"M1.96875 8.50391L1.96904 12.4909L1.9702 12.4833H5.92302V11.5868C5.92302 10.3613 5.92302 9.73358 5.92302 8.50459L1.96875 8.50391Z", // 9
"M1.96875 8.50391L1.96904 12.4909L1.9702 12.4833H5.92302V11.5868C5.92302 10.3613 5.92302 9.73358 5.92302 8.50459L1.96875 8.50391Z", // 10
"M1.96875 6.50391L1.96904 10.4909L1.9702 10.4833H5.92302V9.58685C5.92302 8.3613 5.92302 7.73358 5.92302 6.50459L1.96875 6.50391Z", // 11
"M1.96875 8.50391L1.96904 12.4909L1.9702 12.4833H5.92302V11.5868C5.92302 10.3613 5.92302 9.73358 5.92302 8.50459L1.96875 8.50391Z", // 12
"M1.96875 8.50391L1.96904 12.4909L1.9702 12.4833H5.92302V11.5868C5.92302 10.3613 5.92302 9.73358 5.92302 8.50459L1.96875 8.50391Z", // 13
"M1.96875 8.50391L1.96904 12.4909L1.9702 12.4833H5.92302V11.5868C5.92302 10.3613 5.92302 9.73358 5.92302 8.50459L1.96875 8.50391Z", // 14
"M1.96875 8.50391L1.96904 12.4909L1.9702 12.4833H5.92302V11.5868C5.92302 10.3613 5.92302 9.73358 5.92302 8.50459L1.96875 8.50391Z", // 15
"M6.60156 9.46875L6.60171 11.5326L6.60231 11.5286H8.64841V11.0646C8.64841 10.4302 8.64841 10.1053 8.64841 9.46911L6.60156 9.46875Z", // 16
"M6.60156 9.46875L6.60171 11.5326L6.60231 11.5286H8.64841V11.0646C8.64841 10.4302 8.64841 10.1053 8.64841 9.46911L6.60156 9.46875Z", // 17
"M6.60156 9.46875L6.60171 11.5326L6.60231 11.5286H8.64841V11.0646C8.64841 10.4302 8.64841 10.1053 8.64841 9.46911L6.60156 9.46875Z", // 18
]
const RIGHT_PATHS = [
"M3.03472 6.05861L6.8539 9.91021H1.96777V11.9737H8.3006V17.4781H10.3467V11.5057C10.3467 10.8713 10.0963 10.2621 9.65119 9.81327L4.48145 4.59961L3.03472 6.05861Z", // 0
"M3.03516 6.05861L6.85434 9.91021H6.85044L8.30044 11.9737L8.30104 17.4781H10.3471V11.5057C10.3471 10.8713 10.0967 10.2621 9.65162 9.81327L4.48188 4.59961L3.03516 6.05861Z", // 1
"M8.30024 4.58789L8.2998 9.91036L8.30039 11.9738L8.30099 17.4783H10.3471V11.5058C10.3471 10.8714 10.3471 10.5465 10.3471 9.91036V4.58789H8.30024Z", // 2
"M6.42383 9.9082L6.42412 13.8633L6.42527 13.8557H10.3464V12.9664C10.3464 11.7507 10.3464 11.128 10.3464 9.90888L6.42383 9.9082Z", // 3
"M8.52051 8.50391L8.5208 12.4909L8.52196 12.4833H12.4748V11.5868C12.4748 10.3613 12.4748 9.73358 12.4748 8.50459L8.52051 8.50391Z", // 4
"M8.52051 8.50391L8.5208 12.4909L8.52196 12.4833H12.4748V11.5868C12.4748 10.3613 12.4748 9.73358 12.4748 8.50459L8.52051 8.50391Z", // 5
"M8.52051 6.50391L8.5208 10.4909L8.52196 10.4833H12.4748V9.58685C12.4748 8.3613 12.4748 7.73358 12.4748 6.50459L8.52051 6.50391Z", // 6
"M8.52051 8.50391L8.5208 12.4909L8.52196 12.4833H12.4748V11.5868C12.4748 10.3613 12.4748 9.73358 12.4748 8.50459L8.52051 8.50391Z", // 7
"M8.52051 8.50391L8.5208 12.4909L8.52196 12.4833H12.4748V11.5868C12.4748 10.3613 12.4748 9.73358 12.4748 8.50459L8.52051 8.50391Z", // 8
"M8.52051 6.50391L8.5208 10.4909L8.52196 10.4833H12.4748V9.58685C12.4748 8.3613 12.4748 7.73358 12.4748 6.50459L8.52051 6.50391Z", // 9
"M8.52051 8.50391L8.5208 12.4909L8.52196 12.4833H12.4748V11.5868C12.4748 10.3613 12.4748 9.73358 12.4748 8.50459L8.52051 8.50391Z", // 10
"M8.52051 8.50391L8.5208 12.4909L8.52196 12.4833H12.4748V11.5868C12.4748 10.3613 12.4748 9.73358 12.4748 8.50459L8.52051 8.50391Z", // 11
"M8.52051 6.50391L8.5208 10.4909L8.52196 10.4833H12.4748V9.58685C12.4748 8.3613 12.4748 7.73358 12.4748 6.50459L8.52051 6.50391Z", // 12
"M8.52051 8.50391L8.5208 12.4909L8.52196 12.4833H12.4748V11.5868C12.4748 10.3613 12.4748 9.73358 12.4748 8.50459L8.52051 8.50391Z", // 13
"M8.52051 8.50391L8.5208 12.4909L8.52196 12.4833H12.4748V11.5868C12.4748 10.3613 12.4748 9.73358 12.4748 8.50459L8.52051 8.50391Z", // 14
"M6.42383 9.9082L6.42412 13.8633L6.42527 13.8557H10.3464V12.9664C10.3464 11.7507 10.3464 11.128 10.3464 9.90888L6.42383 9.9082Z", // 15
"M8.30024 4.58789L8.2998 9.91036L8.30039 11.9738L8.30099 17.4783H10.3471V11.5058C10.3471 10.8714 10.3471 10.5465 10.3471 9.91036V4.58789H8.30024Z", // 16
"M3.03516 6.05861L6.85434 9.91021H6.85044L8.30044 11.9737L8.30104 17.4781H10.3471V11.5057C10.3471 10.8713 10.0967 10.2621 9.65162 9.81327L4.48188 4.59961L3.03516 6.05861Z", // 17
"M3.03472 6.05861L6.8539 9.91021H1.96777V11.9737H8.3006V17.4781H10.3467V11.5057C10.3467 10.8713 10.0963 10.2621 9.65119 9.81327L4.48145 4.59961L3.03472 6.05861Z", // 18
]
export function ChatLoader({
size = 80,
colorClassName = "text-white",
label = "",
className = "",
}: ChatLoaderProps) {
const prefersReducedMotion = useReducedMotion()
const t = useMotionValue(0)
const loopDuration = 3.6 // full cycle
const makeMultiInterp = (paths: string[]) => {
if (!paths || paths.length === 0) {
return (_t: number) => ""
}
if (paths.length === 1) {
const only = paths[0]
return (_t: number) => only
}
const options: { maxSegmentLength?: number } = { maxSegmentLength: 0.5 }
const interpolateFn = (flubber as any).interpolate as (
from: string,
to: string,
options?: { maxSegmentLength?: number },
) => (t: number) => string
const segmentInterpolators: Array<(t: number) => string> = []
for (let i = 0; i < paths.length - 1; i++) {
segmentInterpolators.push(
interpolateFn(paths[i]!, paths[i + 1]!, options),
)
}
const segmentCount = segmentInterpolators.length
return (t: number) => {
if (t <= 0) return paths[0] || ""
if (t >= 1) return paths[paths.length - 1] || ""
const scaled = t * segmentCount
const segIndex = Math.min(Math.floor(scaled), segmentCount - 1)
const localT = scaled - segIndex
return segmentInterpolators[segIndex]!(localT)
}
}
const leftInterp = useMemo(
() => (LEFT_PATHS.length ? makeMultiInterp(LEFT_PATHS) : null),
[],
)
const middleInterp = useMemo(
() => (MIDDLE_PATHS.length ? makeMultiInterp(MIDDLE_PATHS) : null),
[],
)
const rightInterp = useMemo(
() => (RIGHT_PATHS.length ? makeMultiInterp(RIGHT_PATHS) : null),
[],
)
// Turn scalar t into d strings
const leftD = useTransform(t, (v) =>
leftInterp ? leftInterp(v) : LEFT_PATHS[0] || "",
)
const middleD = useTransform(t, (v) =>
middleInterp ? middleInterp(v) : MIDDLE_PATHS[0] || "",
)
const rightD = useTransform(t, (v) =>
rightInterp ? rightInterp(v) : RIGHT_PATHS[0] || "",
)
const middleOpacity = useTransform(t, (v) => {
if (v < 0.2) return 0
if (v < 0.3) return (v - 0.2) / 0.1 // fade in
if (v < 0.8) return 1
if (v < 0.9) return 1 - (v - 0.8) / 0.1 // fade out
return 0
})
useEffect(() => {
if (prefersReducedMotion) {
t.set(0)
return
}
const controls = animate(t, [0, 1], {
duration: loopDuration,
ease: "linear",
repeat: Number.POSITIVE_INFINITY,
repeatType: "loop",
repeatDelay: 0.4, // ⬅️ wait 2 seconds at the end before restarting
})
return () => controls.stop()
}, [t, prefersReducedMotion, loopDuration])
return (
<div
role="status"
aria-label={label}
className={`inline-flex flex-col items-center gap-2 ${className}`}
style={{ width: size }}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 36 20"
width={size}
height={(size * 20) / 36}
className={colorClassName}
>
{leftInterp && <motion.path d={leftD as any} fill="currentColor" />}
{rightInterp && <motion.path d={rightD as any} fill="currentColor" />}
{middleInterp && (
<motion.path
d={middleD as any}
fill="currentColor"
style={{ opacity: middleOpacity as any }}
/>
)}
</svg>
{label && (
<span
className="text-xs font-medium text-slate-400"
style={{ fontSize: size * 0.18 }}
>
{label}
</span>
)}
</div>
)
}

View file

@ -31,6 +31,7 @@ import { toast } from "sonner"
import { z } from "zod/v4"
import { analytics } from "@/lib/analytics"
import { cn } from "@lib/utils"
import type { Project } from "@repo/lib/types"
import { motion, AnimatePresence } from "framer-motion"
const clients = {
@ -56,15 +57,6 @@ const mcpMigrationSchema = z.object({
),
})
interface Project {
id: string
name: string
containerTag: string
createdAt: string
updatedAt: string
isExperimental?: boolean
}
interface ConnectAIModalProps {
children: React.ReactNode
open?: boolean

View file

@ -82,7 +82,6 @@ export function Header({ onAddMemory }: { onAddMemory?: () => void }) {
}, [conversations])
useEffect(() => {
console.log("searchParams", searchParams.get("mcp"))
const mcpParam = searchParams.get("mcp")
if (mcpParam === "manual") {
setMcpInitialClient("mcp-url")

View file

@ -0,0 +1,34 @@
import { Logo } from "@ui/assets/Logo"
import { Button } from "@ui/components/button"
export function InitialHeader({
showUserSupermemory,
name,
}: {
showUserSupermemory?: boolean
name?: string
}) {
const userName = name ? `${name.split(" ")[0]}'s` : "My"
return (
<div className="flex p-6 justify-between items-center">
<div className="flex items-center z-10!">
<Logo className="h-7" />
{showUserSupermemory && (
<div className="flex flex-col items-start justify-center ml-2">
<p className="text-[#8B8B8B] text-[11px] leading-tight">{userName}</p>
<p className="text-white font-bold text-xl leading-none -mt-1">
supermemory
</p>
</div>
)}
</div>
<Button
variant="newDefault"
className="rounded-2xl text-base gap-1 h-11! z-10!"
size={"lg"}
>
Memory API <span className="text-xs mt-[4px]">↗</span>
</Button>
</div>
)
}

View file

@ -1,4 +1,4 @@
import { getDocumentIcon } from "@/lib/document-icon"
import { getDocumentIcon } from "@/components/new/document-modal/document-icon"
import {
Drawer,
DrawerContent,

View file

@ -27,7 +27,7 @@ import { useDeleteDocument } from "@lib/queries"
import { useProject } from "@/stores"
import { MemoryDetail } from "./memories-utils/memory-detail"
import { getDocumentIcon } from "@/lib/document-icon"
import { getDocumentIcon } from "@/components/new/document-modal/document-icon"
import { formatDate, getSourceUrl } from "./memories-utils"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>

View file

@ -0,0 +1,408 @@
"use client"
import { $fetch } from "@lib/api"
import { fetchConnectionsFeature } from "@repo/lib/queries"
import type { ConnectionResponseSchema } from "@repo/validation/api"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons"
import { useCustomer } from "autumn-js/react"
import { Check, Loader, Trash2, Zap } from "lucide-react"
import { useEffect, useState } from "react"
import { toast } from "sonner"
import type { z } from "zod"
import { dmSansClassName } from "@/utils/fonts"
import { cn } from "@lib/utils"
import { Button } from "@ui/components/button"
type Connection = z.infer<typeof ConnectionResponseSchema>
type ConnectorProvider = "google-drive" | "notion" | "onedrive"
const CONNECTORS: Record<
ConnectorProvider,
{
title: string
description: string
icon: React.ComponentType<{ className?: string }>
}
> = {
"google-drive": {
title: "Google Drive",
description: "Connect your Google docs, sheets and slides",
icon: GoogleDrive,
},
notion: {
title: "Notion",
description: "Import your Notion pages and databases",
icon: Notion,
},
onedrive: {
title: "OneDrive",
description: "Access your Microsoft Office documents",
icon: OneDrive,
},
} as const
interface ConnectContentProps {
selectedProject: string
}
export function ConnectContent({ selectedProject }: ConnectContentProps) {
const queryClient = useQueryClient()
const autumn = useCustomer()
const [isProUser, setIsProUser] = useState(false)
const [connectingProvider, setConnectingProvider] =
useState<ConnectorProvider | null>(null)
// Check Pro status
useEffect(() => {
if (!autumn.isLoading) {
setIsProUser(
autumn.customer?.products.some(
(product) => product.id === "consumer_pro",
) ?? false,
)
}
}, [autumn.isLoading, autumn.customer])
// Check connections feature limits
const { data: connectionsCheck } = fetchConnectionsFeature(
autumn,
!autumn.isLoading,
)
const connectionsUsed = connectionsCheck?.balance ?? 0
const connectionsLimit = connectionsCheck?.included_usage ?? 0
const canAddConnection = connectionsUsed < connectionsLimit
// Fetch connections
const { data: connections = [], error: connectionsError } = 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 Connection[]
},
staleTime: 30 * 1000,
refetchInterval: 60 * 1000,
refetchIntervalInBackground: true,
})
// Handle connection errors
useEffect(() => {
if (connectionsError) {
toast.error("Failed to load connections", {
description:
connectionsError instanceof Error
? connectionsError.message
: "Unknown error",
})
}
}, [connectionsError])
// Connect mutation
const addConnectionMutation = useMutation({
mutationFn: async (provider: ConnectorProvider) => {
if (!canAddConnection && !isProUser) {
throw new Error(
"Free plan doesn't include connections. Upgrade to Pro for unlimited connections.",
)
}
const response = await $fetch("@post/connections/:provider", {
params: { provider },
body: {
redirectUrl: window.location.href,
containerTags: [selectedProject],
},
})
// biome-ignore lint/style/noNonNullAssertion: its fine
if ("data" in response && !("error" in response.data!)) {
return response.data
}
throw new Error(response.error?.message || "Failed to connect")
},
onSuccess: (data) => {
if (data?.authLink) {
window.location.href = data.authLink
}
},
onError: (error) => {
setConnectingProvider(null)
toast.error("Failed to connect", {
description: error instanceof Error ? error.message : "Unknown error",
})
},
})
// Disconnect mutation
const deleteConnectionMutation = useMutation({
mutationFn: async (connectionId: string) => {
await $fetch(`@delete/connections/${connectionId}`)
},
onSuccess: () => {
toast.success(
"Connection removal has started. supermemory will permanently delete all documents related to the connection in the next few minutes.",
)
queryClient.invalidateQueries({ queryKey: ["connections"] })
},
onError: (error) => {
toast.error("Failed to remove connection", {
description: error instanceof Error ? error.message : "Unknown error",
})
},
})
const handleConnect = (provider: ConnectorProvider) => {
setConnectingProvider(provider)
addConnectionMutation.mutate(provider)
}
const handleDisconnect = (connectionId: string) => {
deleteConnectionMutation.mutate(connectionId)
}
const hasConnections = connections.length > 0
// Helper function to format connection subtext safely
const getConnectionSubtext = (connection: Connection): string => {
if (connection.email) {
return connection.email
}
return "Connected"
}
return (
<div className="h-full flex flex-col pt-4 space-y-4">
<div className="flex items-center justify-between px-2">
<p className="text-[16px] font-semibold">Supermemory Connections</p>
<span className="bg-[#4BA0FA] text-black text-[12px] font-bold px-1 py-[3px] rounded-[3px]">
PRO
</span>
</div>
{/* Connector section - conditional layout based on hasConnections */}
{hasConnections ? (
<div className="grid grid-cols-3 gap-3">
{Object.entries(CONNECTORS).map(([provider, config]) => {
const Icon = config.icon
const isConnecting =
connectingProvider === provider ||
(addConnectionMutation.isPending &&
addConnectionMutation.variables === provider)
return (
<button
key={provider}
type="button"
onClick={() => handleConnect(provider as ConnectorProvider)}
disabled={
!isProUser || isConnecting || addConnectionMutation.isPending
}
className="bg-[#14161A] border border-[rgba(82,89,102,0.2)] rounded-[12px] px-4 py-3 flex items-center justify-center gap-2 hover:bg-[#1B1F24] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Icon className="w-6 h-6 text-[#737373]" />
<p className="text-[14px] font-medium text-center">
{config.title}
</p>
{isConnecting && (
<Loader className="h-4 w-4 animate-spin text-[#4BA0FA]" />
)}
</button>
)
})}
</div>
) : (
<div className="space-y-3">
{Object.entries(CONNECTORS).map(([provider, config]) => {
const Icon = config.icon
const connection = connections.find(
(conn) => conn.provider === provider,
)
const isConnected = !!connection
const isConnecting =
connectingProvider === provider ||
(addConnectionMutation.isPending &&
addConnectionMutation.variables === provider)
return (
<div
key={provider}
className="bg-[#14161A] rounded-[12px] px-4 py-3 flex items-center justify-between gap-3"
>
<div className="flex items-center gap-3 flex-1">
<Icon className="w-6 h-6 text-[#737373]" />
<div className="space-y-[6px] flex-1">
<div className="flex items-center gap-2">
<p className="text-[16px] font-medium">{config.title}</p>
{isConnected && (
<span className="text-[12px] text-[#4BA0FA] font-medium">
{connection.metadata?.syncInProgress
? "Syncing..."
: "Connected"}
</span>
)}
</div>
<p className="text-[16px] text-[#737373]">
{config.description}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{isConnected ? (
<Button
variant="ghost"
size="sm"
onClick={() => handleDisconnect(connection.id)}
disabled={deleteConnectionMutation.isPending}
className="text-[#737373] hover:text-white hover:bg-[#1B1F24] h-8 w-8 p-0"
>
<Trash2 className="h-4 w-4" />
</Button>
) : (
<Button
onClick={() =>
handleConnect(provider as ConnectorProvider)
}
disabled={
!isProUser ||
isConnecting ||
addConnectionMutation.isPending
}
className="bg-[#4BA0FA] text-black hover:bg-[#4BA0FA]/90 text-[14px] font-medium px-3 py-1.5 h-8"
>
{isConnecting ? (
<Loader className="h-4 w-4 animate-spin" />
) : (
"Connect"
)}
</Button>
)}
</div>
</div>
)
})}
</div>
)}
{/* Connected list panel - only when hasConnections */}
{hasConnections && (
<div className="bg-[#14161A] border border-[rgba(82,89,102,0.2)] rounded-[12px] shadow-inside-out px-4 py-4 space-y-4">
<div className="flex items-center justify-between">
<p className="text-[16px] font-semibold">
Connected to Supermemory
</p>
{connectionsLimit > 0 && (
<p className="text-[12px] text-[#737373]">
{connections.length}/{connectionsLimit} connections used
</p>
)}
</div>
<div className="space-y-3">
{connections.map((connection) => {
const config =
CONNECTORS[connection.provider as ConnectorProvider]
if (!config) return null
const Icon = config.icon
const subtext = getConnectionSubtext(connection)
return (
<div
key={connection.id}
className="flex items-center justify-between gap-3"
>
<div className="flex items-center gap-3 flex-1">
<Icon className="w-6 h-6 text-[#737373]" />
<div className="flex-1 min-w-0">
<p className="text-[16px] font-medium truncate">
{config.title}
</p>
<p className="text-[14px] text-[#737373] truncate">
{subtext}
</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleDisconnect(connection.id)}
disabled={deleteConnectionMutation.isPending}
className="text-[#737373] hover:text-white hover:bg-[#1B1F24] h-8 w-8 p-0 shrink-0"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)
})}
</div>
</div>
)}
{/* Empty state panel - only when !hasConnections */}
{!hasConnections && (
<div
id="no-active-connections"
className="bg-[#14161A] shadow-inside-out rounded-[12px] px-4 py-6 h-full mb-4 flex flex-col justify-center items-center"
>
<Zap className="w-6 h-6 text-[#737373] mb-3" />
{!isProUser ? (
<>
<p className="text-[14px] text-[#737373] mb-4 text-center">
<a
href="/pricing"
className="underline text-[#737373] hover:text-white"
>
Upgrade to Pro
</a>{" "}
to get
<br />
Supermemory Connections
</p>
<div className="space-y-2 text-[14px]">
<div className="flex items-center gap-2">
<Check className="w-4 h-4 text-[#4BA0FA]" />
<span>Unlimited memories</span>
</div>
<div className="flex items-center gap-2">
<Check className="w-4 h-4 text-[#4BA0FA]" />
<span>10 connections</span>
</div>
<div className="flex items-center gap-2">
<Check className="w-4 h-4 text-[#4BA0FA]" />
<span>Advanced search</span>
</div>
<div className="flex items-center gap-2">
<Check className="w-4 h-4 text-[#4BA0FA]" />
<span>Priority support</span>
</div>
</div>
</>
) : (
<div
className={cn(
"text-[#737373] text-center max-w-[174px] font-medium",
dmSansClassName(),
)}
>
<p>No connections yet</p>
<p className="text-[12px]">
Choose a service above to import your knowledge
</p>
</div>
)}
</div>
)}
</div>
)
}

View file

@ -0,0 +1,164 @@
"use client"
import { useState, useEffect } from "react"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/utils/fonts"
import { FileIcon } from "lucide-react"
import { useHotkeys } from "react-hotkeys-hook"
export interface FileData {
file: File | null
title: string
description: string
}
interface FileContentProps {
onSubmit?: (data: { file: File; title: string; description: string }) => void
onDataChange?: (data: FileData) => void
isSubmitting?: boolean
isOpen?: boolean
}
export function FileContent({ onSubmit, onDataChange, isSubmitting, isOpen }: FileContentProps) {
const [isDragging, setIsDragging] = useState(false)
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [title, setTitle] = useState("")
const [description, setDescription] = useState("")
const canSubmit = selectedFile !== null && !isSubmitting
const handleSubmit = () => {
if (canSubmit && onSubmit && selectedFile) {
onSubmit({ file: selectedFile, title, description })
}
}
const updateData = (newFile: File | null, newTitle: string, newDescription: string) => {
onDataChange?.({ file: newFile, title: newTitle, description: newDescription })
}
const handleFileChange = (file: File | null) => {
setSelectedFile(file)
updateData(file, title, description)
}
const handleTitleChange = (newTitle: string) => {
setTitle(newTitle)
updateData(selectedFile, newTitle, description)
}
const handleDescriptionChange = (newDescription: string) => {
setDescription(newDescription)
updateData(selectedFile, title, newDescription)
}
useHotkeys("mod+enter", handleSubmit, {
enabled: isOpen && canSubmit,
enableOnFormTags: ["INPUT", "TEXTAREA"],
})
// Reset content when modal closes
useEffect(() => {
if (!isOpen) {
setSelectedFile(null)
setTitle("")
setDescription("")
onDataChange?.({ file: null, title: "", description: "" })
}
}, [isOpen, onDataChange])
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
setIsDragging(true)
}
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
const file = e.dataTransfer.files[0]
if (file) {
handleFileChange(file)
}
}
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
handleFileChange(file)
}
}
return (
<div className={cn("h-full flex flex-col gap-6 pt-4", dmSansClassName())}>
<div className="flex flex-col gap-2">
<p className="text-[16px] font-medium pl-2">
Upload a file (image, pdf, document, sheet)
</p>
<label
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
"relative flex flex-col items-center justify-center gap-3 p-8 rounded-[14px] bg-[#14161A] shadow-inside-out border-2 border-dashed cursor-pointer transition-all",
isDragging
? "border-[#4BA0FA] bg-[#4BA0FA]/10"
: "border-[#737373]/30 hover:border-[#737373]/50",
isSubmitting && "opacity-50 pointer-events-none",
)}
>
<input
type="file"
onChange={handleFileSelect}
disabled={isSubmitting}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.csv,.txt"
/>
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-[#0F1217]">
<FileIcon className="size-6 text-[#737373]" />
</div>
{selectedFile ? (
<div className="text-center">
<p className="text-white font-medium">{selectedFile.name}</p>
<p className="text-[#737373] text-sm">
{(selectedFile.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
) : (
<div className="text-center">
<p className="text-white">
<span className="text-[#4BA0FA]">Click to upload</span> or drag
and drop
</p>
</div>
)}
</label>
</div>
<div className="flex flex-col gap-2">
<p className="text-[14px] font-semibold pl-2">Title (optional)</p>
<input
type="text"
value={title}
onChange={(e) => handleTitleChange(e.target.value)}
placeholder="Give this file a title"
disabled={isSubmitting}
className="w-full p-4 rounded-[14px] bg-[#14161A] shadow-inside-out disabled:opacity-50"
/>
</div>
<div className="flex flex-col gap-2">
<p className="text-[14px] font-semibold pl-2">Description (optional)</p>
<textarea
value={description}
onChange={(e) => handleDescriptionChange(e.target.value)}
placeholder="Add notes or context about this file"
disabled={isSubmitting}
className="w-full p-4 rounded-[14px] bg-[#14161A] shadow-inside-out disabled:opacity-50"
/>
</div>
</div>
)
}

View file

@ -0,0 +1,464 @@
"use client"
import { useState, useEffect, useMemo, useCallback } from "react"
import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/utils/fonts"
import {
FileTextIcon,
GlobeIcon,
ZapIcon,
ChevronsUpDownIcon,
FolderIcon,
Loader2,
} from "lucide-react"
import { Button } from "@ui/components/button"
import { ConnectContent } from "./connections"
import { NoteContent } from "./note"
import { LinkContent, type LinkData } from "./link"
import { FileContent, type FileData } from "./file"
import { useProject } from "@/stores"
import { $fetch } from "@lib/api"
import { DEFAULT_PROJECT_ID } from "@repo/lib/constants"
import type { Project } from "@repo/lib/types"
import { useQuery } from "@tanstack/react-query"
import { motion } from "motion/react"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@repo/ui/components/dropdown-menu"
import { toast } from "sonner"
import { useDocumentMutations } from "./useDocumentMutations"
type TabType = "note" | "link" | "file" | "connect"
interface AddDocumentModalProps {
isOpen: boolean
onClose: () => void
defaultTab?: TabType
}
export function AddDocumentModal({
isOpen,
onClose,
defaultTab,
}: AddDocumentModalProps) {
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent
className={cn(
"w-[80%]! max-w-[1000px]! h-[80%]! max-h-[800px]! border-none bg-[#1B1F24] flex flex-col p-4 gap-3 rounded-[22px]",
dmSansClassName(),
)}
style={{
boxShadow:
"0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
}}
showCloseButton={false}
>
<DialogTitle className="sr-only">Add Document</DialogTitle>
<div className="flex-1 overflow-hidden">
<AddDocument
defaultTab={defaultTab}
onClose={onClose}
isOpen={isOpen}
/>
</div>
</DialogContent>
</Dialog>
)
}
const tabs = [
{
id: "note" as const,
icon: FileTextIcon,
title: "Write a note",
description: "Save your thoughts, notes and summaries, as memories",
},
{
id: "link" as const,
icon: GlobeIcon,
title: "Save a link",
description: "Add any webpage into your searchable knowledge base",
},
{
id: "file" as const,
icon: FileTextIcon,
title: "Upload a file",
description: "Turn any image, PDF or document into contextual memories",
},
{
id: "connect" as const,
icon: ZapIcon,
title: "Connect knowledge bases",
description: "Sync with Google Drive, Notion and OneDrive and import data",
isPro: true,
},
]
export function AddDocument({
defaultTab,
onClose,
isOpen,
}: {
defaultTab?: TabType
onClose: () => void
isOpen?: boolean
}) {
const [activeTab, setActiveTab] = useState<TabType>(defaultTab ?? "note")
const { selectedProject: globalSelectedProject } = useProject()
const [localSelectedProject, setLocalSelectedProject] = useState<string>(
globalSelectedProject,
)
const [isProjectSelectorOpen, setIsProjectSelectorOpen] = useState(false)
// Form data state for button click handling
const [noteContent, setNoteContent] = useState("")
const [linkData, setLinkData] = useState<LinkData>({
url: "",
title: "",
description: "",
})
const [fileData, setFileData] = useState<FileData>({
file: null,
title: "",
description: "",
})
const { noteMutation, linkMutation, fileMutation } = useDocumentMutations({
onClose,
})
useEffect(() => {
setLocalSelectedProject(globalSelectedProject)
}, [globalSelectedProject])
const { data: projects = [], isLoading: isLoadingProjects } = useQuery({
queryKey: ["projects"],
queryFn: async () => {
const response = await $fetch("@get/projects")
if (response.error) {
throw new Error(response.error?.message || "Failed to load projects")
}
return response.data?.projects || []
},
staleTime: 30 * 1000,
})
const projectName = useMemo(() => {
if (localSelectedProject === DEFAULT_PROJECT_ID) return "Default Project"
const found = projects.find(
(p: Project) => p.containerTag === localSelectedProject,
)
return found?.name ?? localSelectedProject
}, [projects, localSelectedProject])
const handleProjectSelect = (containerTag: string) => {
setLocalSelectedProject(containerTag)
setIsProjectSelectorOpen(false)
}
useEffect(() => {
if (defaultTab) {
setActiveTab(defaultTab)
}
}, [defaultTab])
// Submit handlers
const handleNoteSubmit = useCallback(
(content: string) => {
if (!content.trim()) {
toast.error("Please enter some content")
return
}
noteMutation.mutate({ content, project: localSelectedProject })
},
[noteMutation, localSelectedProject],
)
const handleLinkSubmit = useCallback(
(data: LinkData) => {
if (!data.url.trim()) {
toast.error("Please enter a URL")
return
}
linkMutation.mutate({ url: data.url, project: localSelectedProject })
},
[linkMutation, localSelectedProject],
)
const handleFileSubmit = useCallback(
(data: { file: File; title: string; description: string }) => {
if (!data.file) {
toast.error("Please select a file")
return
}
fileMutation.mutate({
file: data.file,
title: data.title || undefined,
description: data.description || undefined,
project: localSelectedProject,
})
},
[fileMutation, localSelectedProject],
)
// Data change handlers
const handleNoteContentChange = useCallback((content: string) => {
setNoteContent(content)
}, [])
const handleLinkDataChange = useCallback((data: LinkData) => {
setLinkData(data)
}, [])
const handleFileDataChange = useCallback((data: FileData) => {
setFileData(data)
}, [])
// Button click handler
const handleButtonClick = () => {
if (activeTab === "note") {
handleNoteSubmit(noteContent)
} else if (activeTab === "link") {
handleLinkSubmit(linkData)
} else if (activeTab === "file") {
if (fileData.file) {
handleFileSubmit(
fileData as { file: File; title: string; description: string },
)
} else {
toast.error("Please select a file")
}
}
}
const isSubmitting =
noteMutation.isPending || linkMutation.isPending || fileMutation.isPending
return (
<div className="h-full flex flex-row text-white space-x-6">
<div className="w-1/3 flex flex-col justify-between">
<div className="flex flex-col gap-1">
{tabs.map((tab) => (
<TabButton
key={tab.id}
active={activeTab === tab.id}
onClick={() => setActiveTab(tab.id)}
icon={tab.icon}
title={tab.title}
description={tab.description}
isPro={tab.isPro}
/>
))}
</div>
<div
data-testid="memories-counter"
className="bg-[#1B1F24] rounded-2xl p-4 mr-4"
style={{
boxShadow:
"0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
}}
>
<div className="flex justify-between items-center mb-2">
<span
className={cn(
"text-white text-[16px] font-medium",
dmSansClassName(),
)}
>
Memories
</span>
<span className={cn("text-[#737373] text-sm", dmSansClassName())}>
120/200
</span>
</div>
<div className="h-1.5 bg-[#0D121A] rounded-full overflow-hidden">
<div
className="h-full bg-[#2261CA] rounded-full"
style={{ width: "60%" }}
/>
</div>
</div>
</div>
<div className="w-2/3 overflow-auto flex flex-col justify-between">
{activeTab === "note" && (
<NoteContent
onSubmit={handleNoteSubmit}
onContentChange={handleNoteContentChange}
isSubmitting={noteMutation.isPending}
isOpen={isOpen}
/>
)}
{activeTab === "link" && (
<LinkContent
onSubmit={handleLinkSubmit}
onDataChange={handleLinkDataChange}
isSubmitting={linkMutation.isPending}
isOpen={isOpen}
/>
)}
{activeTab === "file" && (
<FileContent
onSubmit={handleFileSubmit}
onDataChange={handleFileDataChange}
isSubmitting={fileMutation.isPending}
isOpen={isOpen}
/>
)}
{activeTab === "connect" && (
<ConnectContent selectedProject={localSelectedProject} />
)}
<div className="flex justify-between">
<DropdownMenu
open={isProjectSelectorOpen}
onOpenChange={setIsProjectSelectorOpen}
>
<DropdownMenuTrigger asChild>
<Button
variant="insideOut"
className="gap-2"
disabled={isSubmitting}
>
<FolderIcon className="size-4" />
<span className="max-w-[120px] truncate">
{isLoadingProjects ? "..." : projectName}
</span>
<motion.div
animate={{ rotate: isProjectSelectorOpen ? 180 : 0 }}
transition={{ duration: 0.2 }}
>
<ChevronsUpDownIcon className="size-4" color="#737373" />
</motion.div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="w-56 bg-[#1B1F24] border border-[#2A2E35] rounded-[12px] p-1.5 max-h-64 overflow-y-auto"
>
<DropdownMenuItem
onClick={() => handleProjectSelect(DEFAULT_PROJECT_ID)}
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-[8px] cursor-pointer",
localSelectedProject === DEFAULT_PROJECT_ID
? "bg-[#4BA0FA]/20 text-white"
: "text-[#737373] hover:bg-[#14161A] hover:text-white",
)}
>
<FolderIcon className="h-4 w-4" />
<span className="text-sm font-medium">Default Project</span>
</DropdownMenuItem>
{projects
.filter((p: Project) => p.containerTag !== DEFAULT_PROJECT_ID)
.map((project: Project) => (
<DropdownMenuItem
key={project.id}
onClick={() => handleProjectSelect(project.containerTag)}
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-[8px] cursor-pointer",
localSelectedProject === project.containerTag
? "bg-[#4BA0FA]/20 text-white"
: "text-[#737373] hover:bg-[#14161A] hover:text-white",
)}
>
<FolderIcon className="h-4 w-4" />
<span className="text-sm font-medium truncate">
{project.name}
</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<div className="flex items-center gap-2">
<Button
variant="ghost"
onClick={onClose}
disabled={isSubmitting}
className="text-[#737373] cursor-pointer rounded-full"
>
Cancel
</Button>
{activeTab !== "connect" && (
<Button
variant="insideOut"
onClick={handleButtonClick}
disabled={isSubmitting}
>
{isSubmitting ? (
<>
<Loader2 className="size-4 animate-spin mr-2" />
Adding...
</>
) : (
<>
+ Add {activeTab}{" "}
<span
className={cn(
"bg-[#21212180] border border-[#73737333] text-[#737373] rounded-sm px-1 py-0.5 text-[10px] flex items-center justify-center",
dmSansClassName(),
)}
>
⌘+Enter
</span>
</>
)}
</Button>
)}
</div>
</div>
</div>
</div>
)
}
function TabButton({
active,
onClick,
icon: Icon,
title,
description,
isPro,
}: {
active: boolean
onClick: () => void
icon: React.ComponentType<{ className?: string }>
title: string
description: string
isPro?: boolean
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex items-start gap-3 p-4 rounded-[16px] text-left transition-colors w-full focus:outline-none focus:ring-0",
active
? "bg-[#14161A] shadow-inside-out"
: "hover:bg-[#14161A]/50 hover:shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]",
dmSansClassName(),
)}
>
<Icon className={cn("size-5 mt-0.5 shrink-0 text-white")} />
<div className="flex flex-col gap-0.5 text-[16px]">
<div className="flex items-center justify-between gap-2">
<span className={cn("font-medium text-white", dmSansClassName())}>
{title}
</span>
{isPro && (
<span className="bg-[#4BA0FA] text-black text-[10px] font-semibold px-1.5 py-0.5 rounded">
PRO
</span>
)}
</div>
<span className="text-[#737373]">{description}</span>
</div>
</button>
)
}

View file

@ -0,0 +1,130 @@
"use client"
import { useState, useEffect } from "react"
import { cn } from "@lib/utils"
import { Button } from "@ui/components/button"
import { dmSansClassName } from "@/utils/fonts"
import { useHotkeys } from "react-hotkeys-hook"
export interface LinkData {
url: string
title: string
description: string
}
interface LinkContentProps {
onSubmit?: (data: LinkData) => void
onDataChange?: (data: LinkData) => void
isSubmitting?: boolean
isOpen?: boolean
}
export function LinkContent({ onSubmit, onDataChange, isSubmitting, isOpen }: LinkContentProps) {
const [url, setUrl] = useState("")
const [title, setTitle] = useState("")
const [description, setDescription] = useState("")
const canSubmit = url.trim().length > 0 && !isSubmitting
const handleSubmit = () => {
if (canSubmit && onSubmit) {
onSubmit({ url, title, description })
}
}
const updateData = (newUrl: string, newTitle: string, newDescription: string) => {
onDataChange?.({ url: newUrl, title: newTitle, description: newDescription })
}
const handleUrlChange = (newUrl: string) => {
setUrl(newUrl)
updateData(newUrl, title, description)
}
const handleTitleChange = (newTitle: string) => {
setTitle(newTitle)
updateData(url, newTitle, description)
}
const handleDescriptionChange = (newDescription: string) => {
setDescription(newDescription)
updateData(url, title, newDescription)
}
useHotkeys("mod+enter", handleSubmit, {
enabled: isOpen && canSubmit,
enableOnFormTags: ["INPUT", "TEXTAREA"],
})
// Reset content when modal closes
useEffect(() => {
if (!isOpen) {
setUrl("")
setTitle("")
setDescription("")
onDataChange?.({ url: "", title: "", description: "" })
}
}, [isOpen, onDataChange])
return (
<div className={cn("flex flex-col space-y-4 pt-4", dmSansClassName())}>
<div>
<p
className={cn("text-[16px] font-medium pl-2 pb-2", dmSansClassName())}
>
Paste a link to turn it into a memory
</p>
<div className="flex relative">
<input
type="text"
value={url}
onChange={(e) => handleUrlChange(e.target.value)}
placeholder="https://maheshthedev.me"
disabled={isSubmitting}
className={cn(
"w-full p-4 rounded-xl bg-[#14161A] shadow-inside-out disabled:opacity-50",
)}
/>
<Button variant="linkPreview" className="absolute right-2 top-2" disabled={isSubmitting}>
Preview Link
</Button>
</div>
</div>
<div className="bg-[#14161A] rounded-[14px] py-6 px-4 space-y-4 shadow-inside-out">
<div>
<p className="pl-2 pb-2 font-semibold text-[16px] text-[#737373]">
Link title
</p>
<input
type="text"
value={title}
onChange={(e) => handleTitleChange(e.target.value)}
placeholder="Mahesh Sanikommu - Portfolio"
disabled={isSubmitting}
className="w-full px-4 py-3 bg-[#0F1217] rounded-xl disabled:opacity-50"
/>
</div>
<div>
<p className="pl-2 pb-2 font-semibold text-[16px] text-[#737373]">
Link description
</p>
<textarea
value={description}
onChange={(e) => handleDescriptionChange(e.target.value)}
placeholder="Portfolio website of Mahesh Sanikommu"
disabled={isSubmitting}
className="w-full px-4 py-3 bg-[#0F1217] rounded-xl disabled:opacity-50"
/>
</div>
<div>
<p className="pl-2 pb-2 font-semibold text-[16px] text-[#737373]">
Link Preview
</p>
<div className="w-full px-4 py-3 bg-[#0F1217] rounded-xl">
<p>{description || "Portfolio website of Mahesh Sanikommu"}</p>
</div>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,51 @@
"use client"
import { useState, useEffect } from "react"
import { useHotkeys } from "react-hotkeys-hook"
interface NoteContentProps {
onSubmit?: (content: string) => void
onContentChange?: (content: string) => void
isSubmitting?: boolean
isOpen?: boolean
}
export function NoteContent({ onSubmit, onContentChange, isSubmitting, isOpen }: NoteContentProps) {
const [content, setContent] = useState("")
const canSubmit = content.trim().length > 0 && !isSubmitting
const handleSubmit = () => {
if (canSubmit && onSubmit) {
onSubmit(content)
}
}
const handleContentChange = (newContent: string) => {
setContent(newContent)
onContentChange?.(newContent)
}
useHotkeys("mod+enter", handleSubmit, {
enabled: isOpen && canSubmit,
enableOnFormTags: ["TEXTAREA"],
})
// Reset content when modal closes
useEffect(() => {
if (!isOpen) {
setContent("")
onContentChange?.("")
}
}, [isOpen, onContentChange])
return (
<textarea
value={content}
onChange={(e) => handleContentChange(e.target.value)}
placeholder="Write your note here..."
disabled={isSubmitting}
className="w-full h-full p-4 mb-4! rounded-[14px] bg-[#14161A] shadow-inside-out resize-none disabled:opacity-50"
/>
)
}

View file

@ -0,0 +1,313 @@
"use client"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
import { $fetch } from "@lib/api"
interface DocumentsQueryData {
documents: unknown[]
totalCount: number
}
interface UseDocumentMutationsOptions {
onClose: () => void
}
export function useDocumentMutations({ onClose }: UseDocumentMutationsOptions) {
const queryClient = useQueryClient()
const noteMutation = useMutation({
mutationFn: async ({
content,
project,
}: {
content: string
project: string
}) => {
const response = await $fetch("@post/documents", {
body: {
content: content,
containerTags: [project],
metadata: {
sm_source: "consumer",
},
},
})
if (response.error) {
throw new Error(response.error?.message || "Failed to add note")
}
return response.data
},
onMutate: async ({ content, project }) => {
await queryClient.cancelQueries({
queryKey: ["documents-with-memories", project],
})
const previousMemories = queryClient.getQueryData([
"documents-with-memories",
project,
])
const optimisticMemory = {
id: `temp-${Date.now()}`,
content: content,
url: null,
title: content.substring(0, 100),
description: "Processing content...",
containerTags: [project],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
status: "queued",
type: "note",
metadata: {
processingStage: "queued",
processingMessage: "Added to processing queue",
},
memoryEntries: [],
isOptimistic: true,
}
queryClient.setQueryData(
["documents-with-memories", project],
(old: DocumentsQueryData | undefined) => {
if (!old) return { documents: [optimisticMemory], totalCount: 1 }
return {
...old,
documents: [optimisticMemory, ...old.documents],
totalCount: old.totalCount + 1,
}
},
)
return { previousMemories }
},
onError: (_error, variables, context) => {
if (context?.previousMemories) {
queryClient.setQueryData(
["documents-with-memories", variables.project],
context.previousMemories,
)
}
toast.error("Failed to add note", {
description: _error instanceof Error ? _error.message : "Unknown error",
})
},
onSuccess: (_data, variables) => {
toast.success("Note added successfully!", {
description: "Your note is being processed",
})
queryClient.invalidateQueries({
queryKey: ["documents-with-memories", variables.project],
})
onClose()
},
})
const linkMutation = useMutation({
mutationFn: async ({ url, project }: { url: string; project: string }) => {
const response = await $fetch("@post/documents", {
body: {
content: url,
containerTags: [project],
metadata: {
sm_source: "consumer",
},
},
})
if (response.error) {
throw new Error(response.error?.message || "Failed to add link")
}
return response.data
},
onMutate: async ({ url, project }) => {
await queryClient.cancelQueries({
queryKey: ["documents-with-memories", project],
})
const previousMemories = queryClient.getQueryData([
"documents-with-memories",
project,
])
const optimisticMemory = {
id: `temp-${Date.now()}`,
content: "",
url: url,
title: "Processing...",
description: "Extracting content...",
containerTags: [project],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
status: "queued",
type: "link",
metadata: {
processingStage: "queued",
processingMessage: "Added to processing queue",
},
memoryEntries: [],
isOptimistic: true,
}
queryClient.setQueryData(
["documents-with-memories", project],
(old: DocumentsQueryData | undefined) => {
if (!old) return { documents: [optimisticMemory], totalCount: 1 }
return {
...old,
documents: [optimisticMemory, ...old.documents],
totalCount: old.totalCount + 1,
}
},
)
return { previousMemories }
},
onError: (_error, variables, context) => {
if (context?.previousMemories) {
queryClient.setQueryData(
["documents-with-memories", variables.project],
context.previousMemories,
)
}
toast.error("Failed to add link", {
description: _error instanceof Error ? _error.message : "Unknown error",
})
},
onSuccess: (_data, variables) => {
toast.success("Link added successfully!", {
description: "Your link is being processed",
})
queryClient.invalidateQueries({
queryKey: ["documents-with-memories", variables.project],
})
onClose()
},
})
const fileMutation = useMutation({
mutationFn: async ({
file,
title,
description,
project,
}: {
file: File
title?: string
description?: string
project: string
}) => {
const formData = new FormData()
formData.append("file", file)
formData.append("containerTags", JSON.stringify([project]))
formData.append(
"metadata",
JSON.stringify({
sm_source: "consumer",
}),
)
const response = await fetch(
`${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/documents/file`,
{
method: "POST",
body: formData,
credentials: "include",
},
)
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || "Failed to upload file")
}
const data = await response.json()
if (title || description) {
await $fetch(`@patch/documents/${data.id}`, {
body: {
metadata: {
...(title && { title }),
...(description && { description }),
sm_source: "consumer",
},
},
})
}
return data
},
onMutate: async ({ file, title, description, project }) => {
await queryClient.cancelQueries({
queryKey: ["documents-with-memories", project],
})
const previousMemories = queryClient.getQueryData([
"documents-with-memories",
project,
])
const optimisticMemory = {
id: `temp-file-${Date.now()}`,
content: "",
url: null,
title: title || file.name,
description: description || `Uploading ${file.name}...`,
containerTags: [project],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
status: "processing",
type: "file",
metadata: {
fileName: file.name,
fileSize: file.size,
mimeType: file.type,
},
memoryEntries: [],
}
queryClient.setQueryData(
["documents-with-memories", project],
(old: DocumentsQueryData | undefined) => {
if (!old) return { documents: [optimisticMemory], totalCount: 1 }
return {
...old,
documents: [optimisticMemory, ...old.documents],
totalCount: old.totalCount + 1,
}
},
)
return { previousMemories }
},
onError: (_error, variables, context) => {
if (context?.previousMemories) {
queryClient.setQueryData(
["documents-with-memories", variables.project],
context.previousMemories,
)
}
toast.error("Failed to upload file", {
description: _error instanceof Error ? _error.message : "Unknown error",
})
},
onSuccess: (_data, variables) => {
toast.success("File uploaded successfully!", {
description: "Your file is being processed",
})
queryClient.invalidateQueries({
queryKey: ["documents-with-memories", variables.project],
})
onClose()
},
})
return {
noteMutation,
linkMutation,
fileMutation,
}
}

View file

@ -0,0 +1,68 @@
import { motion } from "motion/react"
export function AnimatedGradientBackground({
topPosition = "40%",
animateFromBottom = true,
}: {
topPosition?: string
animateFromBottom?: boolean
}) {
return (
<div className="fixed inset-0 z-0 overflow-hidden">
<motion.div
className="absolute top-0 left-0 right-0 bottom-0 bg-[url('/onboarding/bg-gradient-0.png')] bg-size-[150%_auto] bg-top bg-no-repeat"
style={{ top: animateFromBottom ? undefined : topPosition }}
initial={{ y: "100%" }}
animate={{
y: 0,
opacity: animateFromBottom ? 0 : [1, 0, 1],
top: animateFromBottom ? "0%" : topPosition,
}}
transition={{
y: { duration: 0.75, ease: "easeOut" },
opacity: animateFromBottom
? { duration: 2, ease: "easeOut" }
: {
duration: 8,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
},
top: animateFromBottom
? { duration: 0.75, ease: "easeOut" }
: undefined,
}}
/>
<motion.div
className="absolute top-0 left-0 right-0 bottom-0 bg-[url('/onboarding/bg-gradient-1.png')] bg-size-[150%_auto] bg-top bg-no-repeat"
style={{ top: animateFromBottom ? undefined : topPosition }}
initial={{ y: "100%" }}
animate={{
y: 0,
opacity: animateFromBottom ? 0 : [0, 1, 0],
top: animateFromBottom ? "0%" : topPosition,
}}
transition={{
y: { duration: 0.75, ease: "easeOut" },
opacity: animateFromBottom
? { duration: 2, ease: "easeOut" }
: {
duration: 8,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
},
top: animateFromBottom
? { duration: 0.75, ease: "easeOut" }
: undefined,
}}
/>
<motion.div
className="absolute top-0 left-0 right-0 bottom-0 bg-[url('/bg-rectangle.png')] bg-cover bg-center bg-no-repeat"
transition={{ duration: 0.75, ease: "easeOut", bounce: 0 }}
style={{
mixBlendMode: "soft-light",
opacity: 0.4,
}}
/>
</div>
)
}

View file

@ -0,0 +1,551 @@
"use client"
import { useState, useEffect, useCallback, useRef } from "react"
import { motion, AnimatePresence } from "motion/react"
import { useChat } from "@ai-sdk/react"
import { DefaultChatTransport } from "ai"
import NovaOrb from "@/components/nova/nova-orb"
import { Button } from "@ui/components/button"
import {
ChevronDownIcon,
HistoryIcon,
PanelRightCloseIcon,
SearchIcon,
SquarePenIcon,
} from "lucide-react"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/utils/fonts"
import ChatInput from "./input"
import ChatModelSelector from "./model-selector"
import { GradientLogo, LogoBgGradient } from "@ui/assets/Logo"
import { useProject, usePersistentChat } from "@/stores"
import type { ModelId } from "@/lib/models"
import { SuperLoader } from "../../superloader"
import { UserMessage } from "./message/user-message"
import { AgentMessage } from "./message/agent-message"
import { ChainOfThought } from "./input/chain-of-thought"
function ChatEmptyStatePlaceholder({
onSuggestionClick,
}: {
onSuggestionClick: (suggestion: string) => void
}) {
const suggestions = [
"Show me all content related to Supermemory.",
"Summarize the key ideas from My Gita.",
"Which memories connect design and AI?",
"What are the main themes across my memories?",
]
return (
<div
id="chat-empty-state"
className="flex flex-col items-center justify-center h-full"
>
<div className="relative w-32 h-32">
<GradientLogo className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-16 h-16" />
<LogoBgGradient className="w-full h-full" />
</div>
<div className="gap-3 flex flex-col items-center justify-center">
<p>Ask me anything about your memories...</p>
<div
className={cn(
dmSansClassName(),
"flex flex-col gap-2 justify-center items-center",
)}
>
{suggestions.map((suggestion) => (
<Button
key={suggestion}
variant="default"
className="rounded-full text-base gap-1 h-10! border-[#2261CA33] bg-[#041127] border w-fit py-[4px] pl-[8px] pr-[12px] hover:bg-[#0A1A3A] hover:[&_span]:text-white hover:[&_svg]:text-white transition-colors cursor-pointer"
onClick={() => onSuggestionClick(suggestion)}
>
<SearchIcon className="size-4 text-[#267BF1]" />
<span className="text-[#267BF1] text-[12px]">{suggestion}</span>
</Button>
))}
</div>
</div>
</div>
)
}
export function ChatSidebar({
isChatOpen,
setIsChatOpen,
}: {
isChatOpen: boolean
setIsChatOpen: (open: boolean) => void
}) {
const [input, setInput] = useState("")
const [selectedModel, setSelectedModel] = useState<ModelId>("gemini-2.5-pro")
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null)
const [hoveredMessageId, setHoveredMessageId] = useState<string | null>(null)
const [messageFeedback, setMessageFeedback] = useState<
Record<string, "like" | "dislike" | null>
>({})
const [expandedMemories, setExpandedMemories] = useState<string | null>(null)
const [followUpQuestions, setFollowUpQuestions] = useState<
Record<string, string[]>
>({})
const [loadingFollowUps, setLoadingFollowUps] = useState<
Record<string, boolean>
>({})
const [isInputExpanded, setIsInputExpanded] = useState(false)
const [isScrolledToBottom, setIsScrolledToBottom] = useState(true)
const pendingFollowUpGenerations = useRef<Set<string>>(new Set())
const messagesContainerRef = useRef<HTMLDivElement>(null)
const { selectedProject } = useProject()
const { setCurrentChatId } = usePersistentChat()
const { messages, sendMessage, status, setMessages, stop } = useChat({
transport: new DefaultChatTransport({
api: `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/v2`,
credentials: "include",
body: {
metadata: {
projectId: selectedProject,
model: selectedModel,
},
},
}),
maxSteps: 10,
onFinish: async (result) => {
if (result.message.role !== "assistant") return
// Mark this message as needing follow-up generation
// We'll generate it after the message is fully in the messages array
if (result.message.id) {
pendingFollowUpGenerations.current.add(result.message.id)
}
},
})
// Generate follow-up questions after assistant messages are complete
useEffect(() => {
const generateFollowUps = async () => {
// Find assistant messages that need follow-up generation
const messagesToProcess = messages.filter(
(msg) =>
msg.role === "assistant" &&
pendingFollowUpGenerations.current.has(msg.id) &&
!followUpQuestions[msg.id] &&
!loadingFollowUps[msg.id],
)
for (const message of messagesToProcess) {
// Get complete text from the message
const assistantText = message.parts
.filter((p) => p.type === "text")
.map((p) => p.text)
.join(" ")
.trim()
// Only generate if we have substantial text (at least 50 chars)
// This ensures the message is complete, not just the first chunk
// Also check if status is idle to ensure streaming is complete
if (
assistantText.length < 50 ||
status === "streaming" ||
status === "submitted"
) {
continue
}
// Mark as processing
pendingFollowUpGenerations.current.delete(message.id)
setLoadingFollowUps((prev) => ({
...prev,
[message.id]: true,
}))
try {
// Get recent messages for context
const recentMessages = messages.slice(-5).map((msg) => ({
role: msg.role,
content: msg.parts
.filter((p) => p.type === "text")
.map((p) => p.text)
.join(" "),
}))
const response = await fetch(
`${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/follow-ups`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify({
messages: recentMessages,
assistantResponse: assistantText,
}),
},
)
if (response.ok) {
const data = await response.json()
if (data.questions && Array.isArray(data.questions)) {
setFollowUpQuestions((prev) => ({
...prev,
[message.id]: data.questions,
}))
}
}
} catch (error) {
console.error("Failed to generate follow-up questions:", error)
} finally {
setLoadingFollowUps((prev) => ({
...prev,
[message.id]: false,
}))
}
}
}
// Only generate if not currently streaming or submitted
// Small delay to ensure message is fully processed
if (status !== "streaming" && status !== "submitted") {
const timeoutId = setTimeout(() => {
generateFollowUps()
}, 300)
return () => clearTimeout(timeoutId)
}
}, [messages, followUpQuestions, loadingFollowUps, status])
const checkIfScrolledToBottom = useCallback(() => {
if (!messagesContainerRef.current) return
const container = messagesContainerRef.current
const { scrollTop, scrollHeight, clientHeight } = container
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
const isAtBottom = distanceFromBottom <= 20
setIsScrolledToBottom(isAtBottom)
}, [])
const scrollToBottom = useCallback(() => {
if (messagesContainerRef.current) {
messagesContainerRef.current.scrollTop =
messagesContainerRef.current.scrollHeight
setIsScrolledToBottom(true)
}
}, [])
const handleSend = () => {
if (!input.trim() || status === "submitted" || status === "streaming")
return
sendMessage({ text: input })
setInput("")
scrollToBottom()
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
const toggleChat = () => {
setIsChatOpen(!isChatOpen)
}
const handleCopyMessage = useCallback((messageId: string, text: string) => {
navigator.clipboard.writeText(text)
setCopiedMessageId(messageId)
setTimeout(() => setCopiedMessageId(null), 2000)
}, [])
const handleLikeMessage = useCallback((messageId: string) => {
setMessageFeedback((prev) => ({
...prev,
[messageId]: prev[messageId] === "like" ? null : "like",
}))
}, [])
const handleDislikeMessage = useCallback((messageId: string) => {
setMessageFeedback((prev) => ({
...prev,
[messageId]: prev[messageId] === "dislike" ? null : "dislike",
}))
}, [])
const handleToggleMemories = useCallback((messageId: string) => {
setExpandedMemories((prev) => (prev === messageId ? null : messageId))
}, [])
const handleNewChat = useCallback(() => {
const newId = crypto.randomUUID()
setCurrentChatId(newId)
setMessages([])
setInput("")
}, [setCurrentChatId, setMessages])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (
e.key.toLowerCase() === "t" &&
!e.metaKey &&
!e.ctrlKey &&
!e.altKey &&
isChatOpen &&
document.activeElement?.tagName !== "INPUT" &&
document.activeElement?.tagName !== "TEXTAREA"
) {
e.preventDefault()
handleNewChat()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [isChatOpen, handleNewChat])
// Scroll to bottom when a new user message is added
useEffect(() => {
const lastMessage = messages[messages.length - 1]
if (lastMessage?.role === "user" && messagesContainerRef.current) {
messagesContainerRef.current.scrollTop =
messagesContainerRef.current.scrollHeight
setIsScrolledToBottom(true)
}
// Always check scroll position when messages change
checkIfScrolledToBottom()
}, [messages, checkIfScrolledToBottom])
// Add scroll event listener to track scroll position
useEffect(() => {
const container = messagesContainerRef.current
if (!container) return
const handleScroll = () => {
requestAnimationFrame(() => {
checkIfScrolledToBottom()
})
}
container.addEventListener("scroll", handleScroll, { passive: true })
// Initial check with a small delay to ensure DOM is ready
setTimeout(() => {
checkIfScrolledToBottom()
}, 100)
// Also observe resize to detect content height changes
const resizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => {
checkIfScrolledToBottom()
})
})
resizeObserver.observe(container)
return () => {
container.removeEventListener("scroll", handleScroll)
resizeObserver.disconnect()
}
}, [checkIfScrolledToBottom])
return (
<AnimatePresence mode="wait">
{!isChatOpen ? (
<motion.div
key="closed"
className={cn(
"absolute top-0 right-0 flex items-start justify-start m-4",
dmSansClassName(),
)}
layoutId="chat-toggle-button"
>
<motion.button
onClick={toggleChat}
className="flex items-center gap-2 rounded-full px-3 py-1.5 text-xs font-medium border border-[#17181A] text-white cursor-pointer"
style={{
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
}}
>
<NovaOrb size={24} className="blur-[0.6px]! z-10" />
Chat with Nova
</motion.button>
</motion.div>
) : (
<motion.div
key="open"
className={cn(
"w-[450px] h-[calc(100vh-95px)] bg-[#05070A] backdrop-blur-md flex flex-col rounded-2xl m-4 mt-2 border border-[#17181AB2] relative pt-4",
dmSansClassName(),
)}
initial={{ x: "100px", opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: "100px", opacity: 0 }}
transition={{ duration: 0.3, ease: "easeOut", bounce: 0 }}
>
<div
className="absolute top-0 left-0 right-0 flex items-center justify-between pt-4 px-4 rounded-t-2xl"
style={{
background:
"linear-gradient(180deg, #0A0E14 40.49%, rgba(10, 14, 20, 0.00) 100%)",
}}
>
<ChatModelSelector
selectedModel={selectedModel}
onModelChange={setSelectedModel}
/>
<div className="flex items-center gap-2">
<Button
variant="headers"
className="rounded-full text-base gap-2 h-10! border-[#73737333] bg-[#0D121A]"
style={{
boxShadow: "1.5px 1.5px 4.5px 0 rgba(0, 0, 0, 0.70) inset",
}}
>
<HistoryIcon className="size-4 text-[#737373]" />
</Button>
<Button
variant="headers"
className="rounded-full text-base gap-3 h-10! border-[#73737333] bg-[#0D121A] cursor-pointer"
style={{
boxShadow: "1.5px 1.5px 4.5px 0 rgba(0, 0, 0, 0.70) inset",
}}
onClick={handleNewChat}
title="New chat (T)"
>
<SquarePenIcon className="size-4 text-[#737373]" />
<span
className={cn(
"bg-[#21212180] border border-[#73737333] text-[#737373] rounded-sm size-4 text-[10px] flex items-center justify-center",
dmSansClassName(),
)}
>
T
</span>
</Button>
<motion.button
onClick={toggleChat}
className="flex items-center gap-2 rounded-full p-2 text-xs text-white cursor-pointer"
layoutId="chat-toggle-button"
>
<PanelRightCloseIcon className="size-4" />
</motion.button>
</div>
</div>
<div
ref={messagesContainerRef}
className={cn(
"flex-1 overflow-y-auto px-4 scrollbar-thin",
dmSansClassName(),
)}
>
{isInputExpanded && (
<div
className="absolute inset-0 z-10! rounded-2xl pointer-events-none"
style={{ backgroundColor: "#000000E5" }}
/>
)}
{messages.length === 0 && (
<ChatEmptyStatePlaceholder
onSuggestionClick={(suggestion) => {
sendMessage({ text: suggestion })
}}
/>
)}
<div
className={cn(
messages.length > 0
? "flex flex-col space-y-3 min-h-full justify-end pt-14"
: "",
)}
>
{messages.map((message, index) => (
// biome-ignore lint/a11y/noStaticElementInteractions: Hover detection for message actions
<div
key={message.id}
className={cn(
"flex gap-2 w-full",
message.role === "user" ? "justify-end" : "justify-start",
)}
onMouseEnter={() =>
message.role === "assistant" &&
setHoveredMessageId(message.id)
}
onMouseLeave={() =>
message.role === "assistant" && setHoveredMessageId(null)
}
>
{message.role === "user" ? (
<UserMessage
message={message}
copiedMessageId={copiedMessageId}
onCopy={handleCopyMessage}
/>
) : (
<AgentMessage
message={message}
index={index}
messagesLength={messages.length}
hoveredMessageId={hoveredMessageId}
copiedMessageId={copiedMessageId}
messageFeedback={messageFeedback}
expandedMemories={expandedMemories}
followUpQuestions={followUpQuestions[message.id] || []}
isLoadingFollowUps={loadingFollowUps[message.id] || false}
onCopy={handleCopyMessage}
onLike={handleLikeMessage}
onDislike={handleDislikeMessage}
onToggleMemories={handleToggleMemories}
onQuestionClick={(question) => {
setInput(question)
}}
/>
)}
</div>
))}
{(status === "submitted" || status === "streaming") &&
messages[messages.length - 1]?.role === "user" && (
<div className="flex gap-2">
<SuperLoader label="Thinking..." />
</div>
)}
</div>
</div>
{!isScrolledToBottom && messages.length > 0 && (
<div className="absolute bottom-24 left-0 right-0 flex justify-center z-50 pointer-events-none">
<button
type="button"
className="cursor-pointer pointer-events-auto"
onClick={scrollToBottom}
>
<div className="rounded-full p-2 bg-[#0D121A] shadow-[1.5px_1.5px_4.5px_0_rgba(0,0,0,0.70)_inset] hover:bg-[#0F1620] transition-colors">
<ChevronDownIcon className="size-4 text-white" />
</div>
</button>
</div>
)}
<ChatInput
value={input}
onChange={(e) => setInput(e.target.value)}
onSend={handleSend}
onStop={stop}
onKeyDown={handleKeyDown}
isResponding={status === "submitted" || status === "streaming"}
activeStatus={
status === "submitted"
? "Thinking..."
: status === "streaming"
? "Structuring response..."
: "Waiting for input..."
}
onExpandedChange={setIsInputExpanded}
chainOfThoughtComponent={
messages.length > 0 ? (
<ChainOfThought messages={messages} />
) : null
}
/>
</motion.div>
)}
</AnimatePresence>
)
}

View file

@ -0,0 +1,50 @@
import { cn } from "@lib/utils"
import { SquareIcon } from "lucide-react"
export function SendButton({
onClick,
disabled,
}: {
onClick: () => void
disabled: boolean
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={cn(
"bg-[#000000] border-[#161F2C] border p-2 rounded-lg shrink-0 transition-opacity",
disabled
? "opacity-50 cursor-not-allowed"
: "cursor-pointer hover:bg-[#161F2C]",
)}
>
<svg
width="16"
height="16"
viewBox="0 0 12 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<title>Send Icon</title>
<path
d="M12 6L10.55 7.4L7 3.85L7 16L5 16L5 3.85L1.45 7.4L-4.37114e-07 6L6 -2.62268e-07L12 6Z"
fill="#FAFAFA"
/>
</svg>
</button>
)
}
export function StopButton({ onClick }: { onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className="bg-[#000000] border-[#161F2C] border p-2 rounded-lg shrink-0 cursor-pointer hover:bg-[#161F2C] transition-opacity"
>
<SquareIcon className="size-4 text-white fill-white" />
</button>
)
}

View file

@ -0,0 +1,215 @@
import { useAuth } from "@lib/auth-context"
import { Avatar, AvatarFallback, AvatarImage } from "@ui/components/avatar"
import type { UIMessage } from "@ai-sdk/react"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/utils/fonts"
interface MemoryResult {
documentId?: string
title?: string
content?: string
url?: string
score?: number
}
interface ReasoningStep {
type: string
state?: string
message: string
}
export function ChainOfThought({ messages }: { messages: UIMessage[] }) {
const { user } = useAuth()
// Group messages into user-assistant pairs
const messagePairs: Array<{
userMessage: UIMessage
agentMessage?: UIMessage
}> = []
for (let i = 0; i < messages.length; i++) {
const message = messages[i]!
if (message.role === "user") {
// Find the next assistant message after this user message
const agentMessage = messages
.slice(i + 1)
.find((msg) => msg.role === "assistant")
messagePairs.push({
userMessage: message,
agentMessage,
})
}
}
return (
<div className="m-3 mb-0 space-y-3 relative z-10">
<div className="absolute left-[11px] top-0 bottom-0 w-px bg-[#151F31] self-stretch mb-0 -z-10" />
{messagePairs.map((pair, pairIdx) => {
const userMessageText =
pair.userMessage.parts.find((part) => part.type === "text")?.text ??
""
const reasoningSteps: ReasoningStep[] = []
if (pair.agentMessage) {
pair.agentMessage.parts.forEach((part) => {
if (part.type === "tool-searchMemories") {
if (
part.state === "input-available" ||
part.state === "input-streaming"
) {
reasoningSteps.push({
type: part.type,
state: part.state,
message: "Searching memories...",
})
} else if (part.state === "output-available") {
reasoningSteps.push({
type: part.type,
state: part.state,
message: "Found relevant memories",
})
} else if (part.state === "output-error") {
reasoningSteps.push({
type: part.type,
state: part.state,
message: "Error searching memories",
})
}
}
})
}
const memoryResults: MemoryResult[] = []
if (pair.agentMessage) {
pair.agentMessage.parts.forEach((part) => {
if (
part.type === "tool-searchMemories" &&
part.state === "output-available"
) {
const output = part.output as
| { results?: MemoryResult[] }
| undefined
const results = Array.isArray(output?.results)
? output.results
: []
memoryResults.push(...results)
}
})
}
return (
<div key={pair.userMessage.id || pairIdx} className="space-y-3">
<div className="flex items-start gap-3 text-[#525D6E] py-1">
{user && (
<Avatar className="size-[21px]">
<AvatarImage src={user?.image ?? ""} />
<AvatarFallback className="text-[10px]">
{user?.name?.charAt(0)}
</AvatarFallback>
</Avatar>
)}
<p>{userMessageText}</p>
</div>
{(reasoningSteps.length > 0 || memoryResults.length > 0) && (
<div className="flex gap-3">
<div className="flex flex-col items-center mx-2 py-1 h-fit">
<div className="size-[7px] rounded-full bg-[#151F31] shrink-0" />
</div>
<div className="flex-1 space-y-1">
{reasoningSteps.length > 0 && (
<div className="space-y-1.5">
{reasoningSteps.map((step, idx) => (
<div
key={`${step.type}-${step.state}-${idx}`}
className="text-[#525D6E]"
>
{step.message}
</div>
))}
</div>
)}
{memoryResults.length > 0 && (
<div className="grid grid-cols-2 gap-2 max-h-64 overflow-y-auto">
{memoryResults.map((result, idx) => {
const isClickable =
result.url &&
(result.url.startsWith("http://") ||
result.url.startsWith("https://"))
const content = (
<div className="">
<div className="bg-[#060D17] p-2 px-[10px] rounded-xl m-[2px]">
{result.title && (
<div className="text-xs text-[#525D6E] line-clamp-2">
{result.title}
</div>
)}
{result.content && (
<div className="text-xs text-[#525D6E]/80 line-clamp-2 mt-1">
{result.content}
</div>
)}
{result.url && (
<div className="text-xs text-[#525D6E] mt-1 truncate">
{result.url}
</div>
)}
</div>
{result.score && (
<div className="flex justify-center p-1">
<div
className={cn(
"text-[10px] inline-block bg-clip-text text-transparent font-medium",
dmSansClassName(),
)}
style={{
backgroundImage:
"var(--grad-1, linear-gradient(94deg, #369BFD 4.8%, #36FDFD 77.04%, #36FDB5 143.99%))",
}}
>
Relevancy score:{" "}
{(result.score * 100).toFixed(1)}%
</div>
</div>
)}
</div>
)
if (isClickable) {
return (
<a
className="block p-2 bg-[#0C1829]/50 rounded-md border border-[#525D6E]/20 hover:bg-[#0C1829]/70 transition-colors cursor-pointer"
href={result.url}
key={result.documentId || idx}
rel="noopener noreferrer"
target="_blank"
>
{content}
</a>
)
}
return (
<div
className={cn("bg-[#0C1829] rounded-xl")}
key={result.documentId || idx}
>
{content}
</div>
)
})}
</div>
)}
</div>
</div>
)}
</div>
)
})}
</div>
)
}

View file

@ -0,0 +1,133 @@
"use client"
import { ChevronUpIcon } from "lucide-react"
import NovaOrb from "@/components/nova/nova-orb"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/utils/fonts"
import { useRef, useState } from "react"
import { motion } from "motion/react"
import { SendButton, StopButton } from "./actions"
interface ChatInputProps {
value: string
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
onSend: () => void
onStop: () => void
onKeyDown?: (e: React.KeyboardEvent) => void
isResponding?: boolean
activeStatus?: string
chainOfThoughtComponent?: React.ReactNode
onExpandedChange?: (expanded: boolean) => void
}
export default function ChatInput({
value,
onChange,
onSend,
onStop,
onKeyDown,
isResponding = false,
activeStatus,
chainOfThoughtComponent,
onExpandedChange,
}: ChatInputProps) {
const [isMultiline, setIsMultiline] = useState(false)
const [isExpanded, setIsExpanded] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
onChange(e)
const textarea = e.target
textarea.style.height = "auto"
// Set height based on scrollHeight, with a max of ~96px (4-5 lines)
const newHeight = Math.min(textarea.scrollHeight, 96)
textarea.style.height = `${newHeight}px`
setIsMultiline(textarea.scrollHeight > 52)
}
return (
<motion.div
className={cn("relative z-20!")}
animate={{
padding: isExpanded ? "16px" : "0",
margin: isExpanded ? "0" : "16px",
borderRadius: isExpanded ? "0 0 12px 12px" : "12px",
backgroundColor: isExpanded ? "#000B1B" : "#01173C",
}}
transition={{
duration: 0.3,
ease: "easeOut",
}}
>
<div
className={cn(
"absolute bottom-full left-0 right-0 overflow-hidden transition-all duration-300 ease-out bg-[#000B1B]",
isExpanded
? "max-h-[60vh] opacity-100 overflow-y-auto pt-1.5 pb-2 rounded-t-xl px-4"
: "max-h-0 opacity-0",
)}
style={{
zIndex: isExpanded ? 50 : 0,
}}
>
{chainOfThoughtComponent}
</div>
<button
type="button"
className={cn(
"w-full p-3 pr-4 flex items-center justify-between cursor-pointer bg-transparent border-0 text-left",
!chainOfThoughtComponent && "disabled:cursor-not-allowed",
)}
onClick={() => {
const newExpanded = !isExpanded
setIsExpanded(newExpanded)
onExpandedChange?.(newExpanded)
}}
disabled={!chainOfThoughtComponent}
>
<div className="flex items-center gap-3">
<NovaOrb size={24} className="blur-[1px]! z-10" />
<p className={cn("text-[#525D6E]", dmSansClassName())}>
{activeStatus || "Waiting for input..."}
</p>
</div>
{chainOfThoughtComponent && (
<ChevronUpIcon
className={cn(
"size-4 text-[#525D6E] transition-transform duration-300",
isExpanded && "rotate-180",
)}
/>
)}
</button>
<div
className={cn(
"flex items-end gap-2 bg-[#070E1B] rounded-xl p-2 border-[#52596633] border focus-within:outline-[#525D6EB2] focus-within:outline-1 transition-all duration-200",
isMultiline && "flex-col",
)}
>
<textarea
ref={textareaRef}
value={value}
onChange={handleChange}
onKeyDown={onKeyDown}
placeholder="Ask your supermemory..."
className="bg-transparent w-full p-2 placeholder:text-[#525D6E] focus:outline-none resize-none overflow-y-auto transition-all duration-200"
style={{ minHeight: "36px" }}
rows={1}
disabled={isResponding}
/>
<div className="transition-all duration-200">
{isResponding ? (
<StopButton onClick={onStop} />
) : (
<SendButton onClick={onSend} disabled={!value.trim()} />
)}
</div>
</div>
</motion.div>
)
}

View file

@ -0,0 +1,108 @@
"use client"
import type { UIMessage } from "@ai-sdk/react"
import { Streamdown } from "streamdown"
import { RelatedMemories } from "./related-memories"
import { MessageActions } from "./message-actions"
import { FollowUpQuestions } from "./follow-up-questions"
interface AgentMessageProps {
message: UIMessage
index: number
messagesLength: number
hoveredMessageId: string | null
copiedMessageId: string | null
messageFeedback: Record<string, "like" | "dislike" | null>
expandedMemories: string | null
followUpQuestions?: string[]
isLoadingFollowUps?: boolean
onCopy: (messageId: string, text: string) => void
onLike: (messageId: string) => void
onDislike: (messageId: string) => void
onToggleMemories: (messageId: string) => void
onQuestionClick?: (question: string) => void
}
export function AgentMessage({
message,
index,
messagesLength,
hoveredMessageId,
copiedMessageId,
messageFeedback,
expandedMemories,
followUpQuestions = [],
isLoadingFollowUps = false,
onCopy,
onLike,
onDislike,
onToggleMemories,
onQuestionClick,
}: AgentMessageProps) {
const isLastAgentMessage =
index === messagesLength - 1 && message.role === "assistant"
const isHovered = hoveredMessageId === message.id
const messageText = message.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join(" ")
return (
<div className="flex flex-col gap-1 w-full">
<div className="flex gap-2">
<div className="flex flex-col gap-2 w-full">
<RelatedMemories
message={message}
expandedMemories={expandedMemories}
onToggle={onToggleMemories}
/>
{message.parts.map((part, partIndex) => {
if (part.type === "text") {
return (
<div
key={`${message.id}-${partIndex}`}
className="text-sm text-white/90 chat-markdown-content"
>
<Streamdown>{part.text}</Streamdown>
</div>
)
}
if (part.type === "tool-searchMemories") {
if (
part.state === "input-available" ||
part.state === "input-streaming"
) {
return (
<div
key={`${message.id}-${partIndex}`}
className="text-xs text-white/50 italic"
>
Searching memories...
</div>
)
}
}
return null
})}
<FollowUpQuestions
questions={followUpQuestions}
isLoading={isLoadingFollowUps}
onQuestionClick={onQuestionClick || (() => {})}
/>
</div>
</div>
<MessageActions
messageId={message.id}
messageText={messageText}
isLastMessage={isLastAgentMessage}
isHovered={isHovered}
copiedMessageId={copiedMessageId}
messageFeedback={messageFeedback}
onCopy={onCopy}
onLike={onLike}
onDislike={onDislike}
/>
</div>
)
}

View file

@ -0,0 +1,56 @@
"use client"
import { ArrowRightIcon } from "lucide-react"
import { cn } from "@lib/utils"
interface FollowUpQuestionsProps {
questions: string[]
onQuestionClick: (question: string) => void
isLoading?: boolean
}
export function FollowUpQuestions({
questions,
onQuestionClick,
isLoading = false,
}: FollowUpQuestionsProps) {
if (isLoading) {
return (
<div className="mt-3 flex flex-col gap-2">
<div
key="skeleton-0"
className="h-4 w-28 animate-pulse rounded-full bg-white/10"
/>
<div
key="skeleton-1"
className="h-4 w-36 animate-pulse rounded-full bg-white/10"
/>
</div>
)
}
if (questions.length === 0) {
return null
}
return (
<div className="flex flex-wrap mt-2 gap-3">
<div className="text-xs">Follow up questions:</div>
<div className="flex flex-wrap">
{questions.map((question) => (
<button
key={question}
type="button"
onClick={() => onQuestionClick(question)}
className={cn(
"group flex items-center gap-1.5 rounded-full py-1 text-sm text-[#267BF1] transition-all hover:underline cursor-pointer text-start",
)}
>
<ArrowRightIcon className="size-3.5" />
<span>{question}</span>
</button>
))}
</div>
</div>
)
}

View file

@ -0,0 +1,80 @@
import { Copy, Check, ThumbsUp, ThumbsDown } from "lucide-react"
import { cn } from "@lib/utils"
interface MessageActionsProps {
messageId: string
messageText: string
isLastMessage: boolean
isHovered: boolean
copiedMessageId: string | null
messageFeedback: Record<string, "like" | "dislike" | null>
onCopy: (messageId: string, text: string) => void
onLike: (messageId: string) => void
onDislike: (messageId: string) => void
}
export function MessageActions({
messageId,
messageText,
isLastMessage,
isHovered,
copiedMessageId,
messageFeedback,
onCopy,
onLike,
onDislike,
}: MessageActionsProps) {
const shouldShowActions = isHovered || isLastMessage
return (
<div
className={cn(
"flex items-center gap-1 transition-opacity duration-200",
shouldShowActions ? "opacity-100" : "opacity-0",
)}
>
<button
type="button"
onClick={() => onCopy(messageId, messageText)}
className="p-1.5 hover:bg-white/10 rounded transition-colors"
title="Copy message"
>
{copiedMessageId === messageId ? (
<Check className="size-3.5 text-green-400" />
) : (
<Copy className="size-3.5 text-white/50 hover:text-white/80" />
)}
</button>
<button
type="button"
onClick={() => onLike(messageId)}
className="p-1.5 hover:bg-white/10 rounded transition-colors"
title="Like message"
>
<ThumbsUp
className={cn(
"size-3.5 transition-colors",
messageFeedback[messageId] === "like"
? "text-green-400 fill-green-400"
: "text-white/50 hover:text-white/80",
)}
/>
</button>
<button
type="button"
onClick={() => onDislike(messageId)}
className="p-1.5 hover:bg-white/10 rounded transition-colors"
title="Dislike message"
>
<ThumbsDown
className={cn(
"size-3.5 transition-colors",
messageFeedback[messageId] === "dislike"
? "text-red-400 fill-red-400"
: "text-white/50 hover:text-white/80",
)}
/>
</button>
</div>
)
}

View file

@ -0,0 +1,135 @@
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import type { UIMessage } from "@ai-sdk/react"
import { dmSansClassName } from "@/utils/fonts"
import { cn } from "@lib/utils"
interface MemoryResult {
documentId?: string
title?: string
content?: string
url?: string
score?: number
}
interface RelatedMemoriesProps {
message: UIMessage
expandedMemories: string | null
onToggle: (messageId: string) => void
}
export function RelatedMemories({
message,
expandedMemories,
onToggle,
}: RelatedMemoriesProps) {
const memoryResults: MemoryResult[] = []
message.parts.forEach((part) => {
if (
part.type === "tool-searchMemories" &&
part.state === "output-available"
) {
const output = part.output as { results?: MemoryResult[] } | undefined
const results = Array.isArray(output?.results) ? output.results : []
memoryResults.push(...results)
}
})
if (memoryResults.length === 0) {
return null
}
const isExpanded = expandedMemories === message.id
return (
<div className="mb-2">
<button
type="button"
className={cn(
"flex items-center gap-2 text-white/50 hover:text-white/70 transition-colors text-sm",
dmSansClassName(),
)}
onClick={() => onToggle(message.id)}
>
Related memories
{isExpanded ? (
<ChevronUpIcon className="size-3.5" />
) : (
<ChevronDownIcon className="size-3.5" />
)}
</button>
{isExpanded && (
<div className="mt-2 grid grid-cols-2 gap-2 max-h-64 overflow-y-auto items-start">
{memoryResults.map((result, idx) => {
const isClickable =
result.url &&
(result.url.startsWith("http://") ||
result.url.startsWith("https://"))
const content = (
<div className="">
<div className="bg-[#060D17] p-2 rounded-t-[11px] rounded-b-[6px] m-[2px]">
{result.title && (
<div className="text-xs text-[#525D6E] line-clamp-2">
{result.title}
</div>
)}
{result.content && (
<div className="text-xs text-[#525D6E] line-clamp-2">
{result.content}
</div>
)}
{result.url && (
<div className="text-xs text-[#525D6E] mt-1 truncate">
{result.url}
</div>
)}
</div>
{result.score && (
<div className="flex justify-center p-[4px]">
<div
className={cn(
"text-[10px] inline-block bg-clip-text text-transparent font-medium",
dmSansClassName(),
)}
style={{
backgroundImage:
"var(--grad-1, linear-gradient(94deg, #369BFD 4.8%, #36FDFD 77.04%, #36FDB5 143.99%))",
}}
>
Relevancy score: {(result.score * 100).toFixed(1)}%
</div>
</div>
)}
</div>
)
if (isClickable) {
return (
<a
className="block p-2 bg-white/5 rounded-md border border-white/10 hover:bg-white/10 transition-colors cursor-pointer"
href={result.url}
key={result.documentId || idx}
rel="noopener noreferrer"
target="_blank"
>
{content}
</a>
)
}
return (
<div
className={cn("bg-[#0C1829] rounded-xl", dmSansClassName())}
key={result.documentId || idx}
>
{content}
</div>
)
})}
</div>
)}
</div>
)
}

View file

@ -0,0 +1,41 @@
"use client"
import { Copy, Check } from "lucide-react"
import type { UIMessage } from "@ai-sdk/react"
interface UserMessageProps {
message: UIMessage
copiedMessageId: string | null
onCopy: (messageId: string, text: string) => void
}
export function UserMessage({
message,
copiedMessageId,
onCopy,
}: UserMessageProps) {
const text = message.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join(" ")
return (
<div className="flex flex-col items-end w-full">
<div className="bg-[#1B1F24] rounded-[12px] p-3 px-[14px] max-w-[80%]">
<p className="text-sm text-white">{text}</p>
</div>
<button
type="button"
onClick={() => onCopy(message.id, text)}
className="p-1.5 hover:bg-[#293952]/40 rounded transition-colors mt-1"
title="Copy message"
>
{copiedMessageId === message.id ? (
<Check className="size-3.5 text-green-400" />
) : (
<Copy className="size-3.5 text-white/50" />
)}
</button>
</div>
)
}

View file

@ -0,0 +1,101 @@
"use client"
import { useState } from "react"
import { cn } from "@lib/utils"
import { Button } from "@ui/components/button"
import { dmSansClassName } from "@/utils/fonts"
import { ChevronDownIcon } from "lucide-react"
import { models, type ModelId, modelNames } from "@/lib/models"
interface ChatModelSelectorProps {
selectedModel?: ModelId
onModelChange?: (model: ModelId) => void
}
export default function ChatModelSelector({
selectedModel: selectedModelProp,
onModelChange,
}: ChatModelSelectorProps = {}) {
const [internalModel, setInternalModel] = useState<ModelId>("gemini-2.5-pro")
const [isOpen, setIsOpen] = useState(false)
const selectedModel = selectedModelProp ?? internalModel
const currentModelData = modelNames[selectedModel]
const handleModelSelect = (modelId: ModelId) => {
if (onModelChange) {
onModelChange(modelId)
} else {
setInternalModel(modelId)
}
setIsOpen(false)
}
return (
<div className="relative flex items-center gap-2">
<Button
variant="headers"
className={cn(
"rounded-full text-base gap-1 h-10! border-[#73737333] bg-[#0D121A]",
dmSansClassName(),
)}
style={{
boxShadow: "1.5px 1.5px 4.5px 0 rgba(0, 0, 0, 0.70) inset",
}}
onClick={() => setIsOpen(!isOpen)}
>
<p className="text-sm">
{currentModelData.name}{" "}
<span className="text-[#737373]">{currentModelData.version}</span>
</p>
<ChevronDownIcon className="size-4 text-[#737373]" />
</Button>
{isOpen && (
<>
<button
type="button"
className="fixed inset-0 z-40"
onClick={() => setIsOpen(false)}
onKeyDown={(e) => e.key === "Escape" && setIsOpen(false)}
aria-label="Close model selector"
/>
<div className="absolute top-full left-0 mt-2 w-64 bg-[#0D121A] backdrop-blur-xl border border-[#73737333] rounded-lg shadow-xl z-50 overflow-hidden">
<div className="p-2 space-y-1">
{models.map((model) => {
const modelData = modelNames[model.id]
return (
<button
key={model.id}
type="button"
className={cn(
"flex flex-col items-start p-2 px-3 rounded-md transition-colors cursor-pointer w-full text-left",
selectedModel === model.id
? "bg-[#293952]/60"
: "hover:bg-[#293952]/40",
)}
onClick={() => handleModelSelect(model.id)}
onKeyDown={(e) =>
e.key === "Enter" && handleModelSelect(model.id)
}
>
<div className="text-sm font-medium text-white">
{modelData.name}{" "}
<span className="text-[#737373]">
{modelData.version}
</span>
</div>
<div className="text-xs text-[#737373] truncate w-full">
{model.description}
</div>
</button>
)
})}
</div>
</div>
</>
)}
</div>
)
}

View file

@ -0,0 +1,188 @@
"use client"
import { useState } from "react"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import type { z } from "zod"
import { dmSansClassName } from "@/utils/fonts"
import { cn } from "@lib/utils"
import { PDF } from "@ui/assets/icons"
import { FileText, Image, Video } from "lucide-react"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
function PDFIcon() {
return (
<svg
width="8"
height="10"
viewBox="0 0 8 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<title>PDF Icon</title>
<g filter="url(#filter0_i_719_6586)">
<path
d="M1 10C0.725 10 0.489583 9.90208 0.29375 9.70625C0.0979167 9.51042 0 9.275 0 9V1C0 0.725 0.0979167 0.489583 0.29375 0.29375C0.489583 0.0979167 0.725 0 1 0H5L8 3V9C8 9.275 7.90208 9.51042 7.70625 9.70625C7.51042 9.90208 7.275 10 7 10H1ZM4.5 3.5V1H1V9H7V3.5H4.5Z"
fill="#FF7673"
/>
</g>
<defs>
<filter
id="filter0_i_719_6586"
x="0"
y="0"
width="8.25216"
height="10.2522"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dx="0.252163" dy="0.252163" />
<feGaussianBlur stdDeviation="0.504325" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0.0431373 0 0 0 0 0.0588235 0 0 0 0 0.0823529 0 0 0 0.4 0"
/>
<feBlend
mode="normal"
in2="shape"
result="effect1_innerShadow_719_6586"
/>
</filter>
</defs>
</svg>
)
}
function getFileTypeInfo(document: DocumentWithMemories): {
icon: React.ReactNode
extension: string
color?: string
} {
const type = document.type?.toLowerCase()
const mimeType = document.metadata?.mimeType as string | undefined
if (mimeType) {
if (mimeType === "application/pdf") {
return {
icon: <PDFIcon />,
extension: ".pdf",
color: "#FF7673",
}
}
if (mimeType.startsWith("image/")) {
const ext = mimeType.split("/")[1] || "jpg"
return {
icon: <Image className="w-4 h-4" style={{ color: "#FAFAFA" }} />,
extension: `.${ext}`,
}
}
if (mimeType.startsWith("video/")) {
const ext = mimeType.split("/")[1] || "mp4"
return {
icon: <Video className="w-4 h-4" style={{ color: "#FAFAFA" }} />,
extension: `.${ext}`,
}
}
}
switch (type) {
case "pdf":
return {
icon: <PDF className="w-4 h-4 text-[#FF7673]" />,
extension: ".pdf",
color: "#FF7673",
}
case "image":
return {
icon: <Image className="w-4 h-4" style={{ color: "#FAFAFA" }} />,
extension: ".jpg",
}
case "video":
return {
icon: <Video className="w-4 h-4" style={{ color: "#FAFAFA" }} />,
extension: ".mp4",
}
default:
return {
icon: <FileText className="w-4 h-4" style={{ color: "#FAFAFA" }} />,
extension: ".file",
}
}
}
export function FilePreview({ document }: { document: DocumentWithMemories }) {
const [imageError, setImageError] = useState(false)
const { icon, extension, color } = getFileTypeInfo(document)
const type = document.type?.toLowerCase()
const mimeType = document.metadata?.mimeType as string | undefined
const isImage =
(mimeType?.startsWith("image/") || type === "image") &&
document.url &&
!imageError
return (
<div className="bg-[#0B1017] rounded-[18px] gap-3 relative overflow-hidden">
{color && (
<div
className="absolute left-0 top-3 bottom-3 w-[2px]"
style={{
background: `linear-gradient(to bottom, transparent, ${color} 10%, ${color} 90%, transparent)`,
}}
/>
)}
{isImage && document.url ? (
<div className="relative w-full overflow-hidden flex items-center justify-center">
<div
className="absolute inset-0 bg-cover bg-center"
style={{
backgroundImage: `url(${document.url})`,
filter: "blur(10px)",
transform: "scale(1.1)",
}}
/>
<div className="absolute inset-0 bg-black/20" />
<img
src={document.url}
alt={document.title || "Image preview"}
className="relative max-w-full max-h-full w-auto h-auto object-contain z-10"
onError={() => setImageError(true)}
loading="lazy"
/>
</div>
) : (
<div className="p-3">
<div className="flex items-center gap-1 mb-2">
{icon}
<p
className={cn(dmSansClassName(), "text-[10px] font-semibold")}
style={{ color: color }}
>
{extension}
</p>
</div>
{document.content && (
<p className="text-[10px] text-[#737373] line-clamp-4">
{document.content}
</p>
)}
</div>
)}
</div>
)
}

View file

@ -0,0 +1,62 @@
"use client"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import type { z } from "zod"
import { dmSansClassName } from "@/utils/fonts"
import { cn } from "@lib/utils"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
export function GoogleDocsPreview({
document,
}: {
document: DocumentWithMemories
}) {
return (
<div className="bg-[#0B1017] p-3 rounded-[18px] gap-3">
<div className="flex items-center gap-2 mb-2">
<svg
className="w-4 h-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 87.3 78"
aria-label="Google Docs"
>
<title>Google Docs</title>
<path
fill="#0066da"
d="m6.6 66.85 3.85 6.65c.8 1.4 1.95 2.5 3.3 3.3L27.5 53H0c0 1.55.4 3.1 1.2 4.5z"
/>
<path
fill="#00ac47"
d="M43.65 25 29.9 1.2c-1.35.8-2.5 1.9-3.3 3.3l-25.4 44A9.06 9.06 0 0 0 0 53h27.5z"
/>
<path
fill="#ea4335"
d="M73.55 76.8c1.35-.8 2.5-1.9 3.3-3.3l1.6-2.75L86.1 57.5c.8-1.4 1.2-2.95 1.2-4.5H59.798l5.852 11.5z"
/>
<path
fill="#00832d"
d="M43.65 25 57.4 1.2C56.05.4 54.5 0 52.9 0H34.4c-1.6 0-3.15.45-4.5 1.2z"
/>
<path
fill="#2684fc"
d="M59.8 53H27.5L13.75 76.8c1.35.8 2.9 1.2 4.5 1.2h50.8c1.6 0 3.15-.45 4.5-1.2z"
/>
<path
fill="#ffba00"
d="m73.4 26.5-12.7-22c-.8-1.4-1.95-2.5-3.3-3.3L43.65 25 59.8 53h27.45c0-1.55-.4-3.1-1.2-4.5z"
/>
</svg>
<p className={cn(dmSansClassName(), "text-[12px] font-semibold")}>
Google Docs
</p>
</div>
{document.content && (
<p className="text-[10px] text-[#737373] line-clamp-4">
{document.content}
</p>
)}
</div>
)
}

View file

@ -0,0 +1,36 @@
"use client"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import type { z } from "zod"
import { dmSansClassName } from "@/utils/fonts"
import { cn } from "@lib/utils"
import { ClaudeDesktopIcon, MCPIcon } from "@ui/assets/icons"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
export function McpPreview({ document }: { document: DocumentWithMemories }) {
return (
<div className="bg-[#0B1017] p-3 rounded-[18px] space-y-2">
<div className="flex items-center justify-between gap-1">
<p className={cn(dmSansClassName(), "text-[12px] font-semibold flex items-center gap-1")}>
<ClaudeDesktopIcon className="size-3" />
Claude Desktop
</p>
<MCPIcon className="size-6" />
</div>
<div className="space-y-[6px]">
{document.title && (
<p className={cn(dmSansClassName(), "text-[12px] font-semibold")}>
{document.title}
</p>
)}
{document.content && (
<p className="text-[10px] text-[#737373] line-clamp-4">
{document.content}
</p>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,109 @@
"use client"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import type { z } from "zod"
import { dmSansClassName } from "@/utils/fonts"
import { cn } from "@lib/utils"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
function NoteIcon() {
return (
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<title>Note Icon</title>
<mask
id="mask0_344_4970"
style={{ maskType: "alpha" }}
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="14"
height="14"
>
<rect width="14" height="14" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_344_4970)">
<g filter="url(#filter0_i_344_4970)">
<path
d="M3.50002 8.75008H7.58335L8.75002 7.58341H3.50002V8.75008ZM3.50002 6.41675H7.00002V5.25008H3.50002V6.41675ZM2.33335 4.08341V9.91675H6.41669L5.25002 11.0834H1.16669V2.91675H12.8334V4.66675H11.6667V4.08341H2.33335ZM13.3584 7.17508C13.407 7.22369 13.4313 7.27716 13.4313 7.3355C13.4313 7.39383 13.407 7.4473 13.3584 7.49591L12.8334 8.02091L11.8125 7.00008L12.3375 6.47508C12.3861 6.42647 12.4396 6.40216 12.4979 6.40216C12.5563 6.40216 12.6097 6.42647 12.6584 6.47508L13.3584 7.17508ZM7.58335 12.2501V11.2292L11.4625 7.35008L12.4834 8.37091L8.60419 12.2501H7.58335Z"
fill="#FAFAFA"
/>
</g>
</g>
<defs>
<filter
id="filter0_i_344_4970"
x="1.16669"
y="2.91675"
width="12.6176"
height="9.68628"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dx="0.353028" dy="0.353028" />
<feGaussianBlur stdDeviation="0.706055" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0.0431373 0 0 0 0 0.0588235 0 0 0 0 0.0823529 0 0 0 0.4 0"
/>
<feBlend
mode="normal"
in2="shape"
result="effect1_innerShadow_344_4970"
/>
</filter>
</defs>
</svg>
)
}
export function NotePreview({ document }: { document: DocumentWithMemories }) {
return (
<div className="bg-[#0B1017] p-3 rounded-[18px] space-y-2">
<div className="flex items-center gap-1">
<NoteIcon />
<p className={cn(dmSansClassName(), "text-[12px] font-semibold")}>
Note
</p>
</div>
<div>
{document.title && (
<p
className={cn(
dmSansClassName(),
"text-[12px] font-semibold line-clamp-2 leading-[125%]",
)}
>
{document.title}
</p>
)}
{document.content && (
<p className="text-[10px] text-[#737373] line-clamp-4">
{document.content}
</p>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,51 @@
"use client"
import { Suspense } from "react"
import type { Tweet } from "react-tweet/api"
import {
TweetContainer,
TweetHeader,
TweetBody,
TweetMedia,
enrichTweet,
TweetSkeleton,
} from "react-tweet"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/utils/fonts"
export function TweetPreview({
data,
noBgColor,
}: {
data: Tweet
noBgColor?: boolean
}) {
const parsedTweet = typeof data === "string" ? JSON.parse(data) : data
const tweet = enrichTweet(parsedTweet)
return (
<div
className={cn(
"p-3 sm-tweet-theme w-full min-w-0",
noBgColor
? "bg-transparent rounded-none"
: "bg-[#0B1017]! rounded-[18px]",
)}
>
<Suspense fallback={<TweetSkeleton />}>
<TweetContainer
className={cn(
"pb-0! my-0! bg-transparent! border-none! w-full! min-w-0!",
dmSansClassName(),
)}
>
<TweetHeader tweet={tweet} components={{}} />
<TweetBody tweet={tweet} />
{tweet.mediaDetails?.length ? (
<TweetMedia tweet={tweet} components={{}} />
) : null}
</TweetContainer>
</Suspense>
</div>
)
}

View file

@ -0,0 +1,52 @@
"use client"
import { useState } from "react"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import type { z } from "zod"
import { dmSansClassName } from "@/utils/fonts"
import { cn } from "@lib/utils"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
export function WebsitePreview({
document,
}: {
document: DocumentWithMemories
}) {
const [imageError, setImageError] = useState(false)
const ogImage = (document as DocumentWithMemories & { ogImage?: string })
.ogImage
return (
<div className="bg-[#0B1017] rounded-[18px] overflow-hidden">
{ogImage && !imageError ? (
<div className="relative w-full aspect-video bg-gray-100 overflow-hidden">
<img
src={ogImage}
alt={document.title || "Website preview"}
className="w-full h-full object-cover"
onError={() => setImageError(true)}
loading="lazy"
/>
</div>
) : (
<div className="p-3 gap-2">
<p
className={cn(
dmSansClassName(),
"text-[12px] font-semibold text-[#E5E5E5] line-clamp-2 mb-1",
)}
>
{document.title || "Untitled Document"}
</p>
{document.content && (
<p className="text-[10px] text-[#737373] line-clamp-3">
{document.content}
</p>
)}
</div>
)}
</div>
)
}

View file

@ -0,0 +1,52 @@
"use client"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import type { z } from "zod"
import { dmSansClassName } from "@/utils/fonts"
import { cn } from "@lib/utils"
import { extractYouTubeVideoId } from "../utils"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
export function YoutubePreview({
document,
}: {
document: DocumentWithMemories
}) {
const videoId = extractYouTubeVideoId(document.url)
if (!videoId) {
return (
<div className="bg-[#0B1017] p-3 rounded-[18px] space-y-2">
{document.title && (
<p className={cn(dmSansClassName(), "text-[12px] font-semibold")}>
{document.title}
</p>
)}
{document.content && (
<p className="text-[10px] text-[#737373] line-clamp-4">
{document.content}
</p>
)}
</div>
)
}
const embedUrl = `https://www.youtube.com/embed/${videoId}`
return (
<div className="bg-[#0B1017] rounded-[18px] overflow-hidden">
<div className="relative w-full aspect-video bg-black">
<iframe
src={embedUrl}
title={document.title || "YouTube video"}
className="w-full h-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
loading="lazy"
/>
</div>
</div>
)
}

View file

@ -0,0 +1,9 @@
import { Streamdown } from "streamdown"
export function NotionDoc({ content }: { content: string }) {
return (
<div className="p-4 overflow-y-auto flex-1 scrollbar-thin">
<Streamdown>{content}</Streamdown>
</div>
)
}

View file

@ -0,0 +1,83 @@
"use client"
import { Document, Page, pdfjs } from "react-pdf"
import { useState } from "react"
import "react-pdf/dist/Page/AnnotationLayer.css"
import "react-pdf/dist/Page/TextLayer.css"
// Configure PDF.js worker to use local package
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
"pdfjs-dist/build/pdf.worker.min.mjs",
import.meta.url,
).toString()
interface PdfViewerProps {
url: string | null | undefined
}
export function PdfViewer({ url }: PdfViewerProps) {
const [numPages, setNumPages] = useState<number | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
if (!url) {
return (
<div className="flex items-center justify-center h-full text-gray-400">
No PDF URL provided
</div>
)
}
function onDocumentLoadSuccess({ numPages }: { numPages: number }) {
setNumPages(numPages)
setLoading(false)
setError(null)
}
function onDocumentLoadError(error: Error) {
setError(error.message || "Failed to load PDF")
setLoading(false)
}
return (
<div className="flex flex-col h-full w-full overflow-hidden scrollbar-thin">
{loading && (
<div className="flex items-center justify-center h-full text-gray-400">
Loading PDF...
</div>
)}
{error && (
<div className="flex items-center justify-center h-full text-red-400">
Error: {error}
</div>
)}
<div className="flex-1 overflow-auto w-full">
<Document
file={
url || "https://corsproxy.io/?" +
encodeURIComponent("http://www.pdf995.com/samples/pdf.pdf")
}
onLoadSuccess={onDocumentLoadSuccess}
onLoadError={onDocumentLoadError}
loading={null}
className="w-full"
>
{numPages && (
<div className="flex flex-col items-center gap-4 py-4 w-full">
{Array.from(new Array(numPages), (_, index) => (
<Page
key={`page_${index + 1}`}
pageNumber={index + 1}
renderTextLayer
renderAnnotationLayer
className="shadow-lg"
width={630}
/>
))}
</div>
)}
</Document>
</div>
</div>
)
}

View file

@ -0,0 +1,37 @@
"use client"
import type { Tweet } from "react-tweet/api"
import { TweetPreview } from "@/components/new/document-cards/tweet-preview"
import { ExternalLinkIcon } from "lucide-react"
interface TweetContentProps {
url?: string | null
tweetMetadata?: unknown
}
export function TweetContent({ url, tweetMetadata }: TweetContentProps) {
if (tweetMetadata) {
return (
<div className="flex-1 flex items-center justify-center w-full p-4 overflow-auto">
<TweetPreview data={tweetMetadata as Tweet} noBgColor />
</div>
)
}
return (
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-gray-400">
<p>Tweet preview unavailable</p>
{url && (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-sm text-blue-400 hover:underline"
>
View on X
<ExternalLinkIcon className="w-3.5 h-3.5" />
</a>
)}
</div>
)
}

View file

@ -0,0 +1,89 @@
"use client"
import { useState, useEffect } from "react"
interface YoutubeVideoProps {
url: string | null | undefined
}
// Extract YouTube video ID from various URL formats
function extractVideoId(url: string): string | null {
if (!url) return null
const patterns = [
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/,
/youtube\.com\/watch\?.*v=([^&\n?#]+)/,
]
for (const pattern of patterns) {
const match = url.match(pattern)
if (match?.[1]) {
return match[1]
}
}
return null
}
export function YoutubeVideo({ url }: YoutubeVideoProps) {
const [videoId, setVideoId] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!url) {
setError("No YouTube URL provided")
setLoading(false)
return
}
const id = extractVideoId(url)
if (!id) {
setError("Invalid YouTube URL format")
setLoading(false)
return
}
setVideoId(id)
setLoading(false)
setError(null)
}, [url])
if (!url) {
return (
<div className="flex items-center justify-center h-full text-gray-400">
No YouTube URL provided
</div>
)
}
if (loading) {
return (
<div className="flex items-center justify-center h-full text-gray-400">
Loading video...
</div>
)
}
if (error || !videoId) {
return (
<div className="flex items-center justify-center h-full text-red-400">
Error: {error || "Failed to extract video ID"}
</div>
)
}
return (
<div className="flex-1 flex items-center justify-center w-full p-4">
<div className="w-full max-w-4xl aspect-video">
<iframe
src={`https://www.youtube.com/embed/${videoId}`}
title="YouTube video player"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
className="w-full h-full rounded-lg shadow-lg"
/>
</div>
</div>
)
}

View file

@ -0,0 +1,263 @@
import { MCPIcon } from "@/components/menu"
import { colors } from "@repo/ui/memory-graph/constants"
import {
GoogleDocs,
MicrosoftWord,
NotionDoc,
GoogleDrive,
GoogleSheets,
GoogleSlides,
OneDrive,
MicrosoftOneNote,
MicrosoftPowerpoint,
MicrosoftExcel,
} from "@ui/assets/icons"
import { Globe } from "lucide-react"
import { useState } from "react"
const getFaviconUrl = (url: string): string => {
try {
const domain = new URL(url).hostname
return `https://www.google.com/s2/favicons?domain=${domain}&sz=32`
} catch {
return ""
}
}
const FaviconIcon = ({
url,
className,
iconProps,
}: {
url: string
className: string
iconProps: { className: string; style: { color: string } }
}) => {
const [hasError, setHasError] = useState(false)
const faviconUrl = getFaviconUrl(url)
if (hasError || !faviconUrl) {
return <Globe {...iconProps} />
}
return (
<img
src={faviconUrl}
alt="Website favicon"
className={className}
style={{
width: "2em",
height: "2em",
objectFit: "contain",
}}
onError={() => setHasError(true)}
/>
)
}
const PDFIcon = ({ className }: { className: string }) => {
return (
<svg
width="8"
height="10"
viewBox="0 0 8 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<title>PDF Icon</title>
<g filter="url(#filter0_i_719_6586)">
<path
d="M1 10C0.725 10 0.489583 9.90208 0.29375 9.70625C0.0979167 9.51042 0 9.275 0 9V1C0 0.725 0.0979167 0.489583 0.29375 0.29375C0.489583 0.0979167 0.725 0 1 0H5L8 3V9C8 9.275 7.90208 9.51042 7.70625 9.70625C7.51042 9.90208 7.275 10 7 10H1ZM4.5 3.5V1H1V9H7V3.5H4.5Z"
fill="#FF7673"
/>
</g>
<defs>
<filter
id="filter0_i_719_6586"
x="0"
y="0"
width="8.25216"
height="10.2522"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dx="0.252163" dy="0.252163" />
<feGaussianBlur stdDeviation="0.504325" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0.0431373 0 0 0 0 0.0588235 0 0 0 0 0.0823529 0 0 0 0.4 0"
/>
<feBlend
mode="normal"
in2="shape"
result="effect1_innerShadow_719_6586"
/>
</filter>
</defs>
</svg>
)
}
const YouTubeIcon = ({ className }: { className: string }) => {
return (
<svg
width="20"
height="14"
viewBox="0 0 20 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<title>YouTube Icon</title>
<path
d="M8 10L13.19 7L8 4V10ZM19.56 2.17C19.69 2.64 19.78 3.27 19.84 4.07C19.91 4.87 19.94 5.56 19.94 6.16L20 7C20 9.19 19.84 10.8 19.56 11.83C19.31 12.73 18.73 13.31 17.83 13.56C17.36 13.69 16.5 13.78 15.18 13.84C13.88 13.91 12.69 13.94 11.59 13.94L10 14C5.81 14 3.2 13.84 2.17 13.56C1.27 13.31 0.69 12.73 0.44 11.83C0.31 11.36 0.22 10.73 0.16 9.93C0.0900001 9.13 0.0599999 8.44 0.0599999 7.84L0 7C0 4.81 0.16 3.2 0.44 2.17C0.69 1.27 1.27 0.69 2.17 0.44C2.64 0.31 3.5 0.22 4.82 0.16C6.12 0.0899998 7.31 0.0599999 8.41 0.0599999L10 0C14.19 0 16.8 0.16 17.83 0.44C18.73 0.69 19.31 1.27 19.56 2.17Z"
fill="#FF0034"
/>
<path d="M8 10L13.19 7L8 4V10Z" fill="white" />
</svg>
)
}
const TextDocumentIcon = ({ className }: { className: string }) => {
return (
<svg
width="18"
height="14"
viewBox="0 0 18 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<title>Text Document Icon</title>
<g filter="url(#filter0_i_724_34196)">
<path
d="M3.33333 8.33333H9.16667L10.8333 6.66667H3.33333V8.33333ZM3.33333 5H8.33333V3.33333H3.33333V5ZM1.66667 1.66667V10H7.5L5.83333 11.6667H0V0H16.6667V2.5H15V1.66667H1.66667ZM17.4167 6.08333C17.4861 6.15278 17.5208 6.22917 17.5208 6.3125C17.5208 6.39583 17.4861 6.47222 17.4167 6.54167L16.6667 7.29167L15.2083 5.83333L15.9583 5.08333C16.0278 5.01389 16.1042 4.97917 16.1875 4.97917C16.2708 4.97917 16.3472 5.01389 16.4167 5.08333L17.4167 6.08333ZM9.16667 13.3333V11.875L14.7083 6.33333L16.1667 7.79167L10.625 13.3333H9.16667Z"
fill="#FAFAFA"
/>
</g>
<defs>
<filter
id="filter0_i_724_34196"
x="0"
y="0"
width="18.0253"
height="13.8376"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dx="0.504325" dy="0.504325" />
<feGaussianBlur stdDeviation="1.00865" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0.0431373 0 0 0 0 0.0588235 0 0 0 0 0.0823529 0 0 0 0.4 0"
/>
<feBlend
mode="normal"
in2="shape"
result="effect1_innerShadow_724_34196"
/>
</filter>
</defs>
</svg>
)
}
export const getDocumentIcon = (
type: string,
className: string,
source?: string,
url?: string,
) => {
const iconProps = {
className,
style: { color: colors.text.muted },
}
if (source === "mcp") {
return <MCPIcon {...iconProps} />
}
if (url?.includes("youtube.com") || url?.includes("youtu.be")) {
return <YouTubeIcon className={className} />
}
if (
type === "webpage" ||
type === "url" ||
(url && (type === "unknown" || !type))
) {
if (url) {
return (
<FaviconIcon url={url} className={className} iconProps={iconProps} />
)
}
return <Globe {...iconProps} />
}
switch (type) {
case "tweet":
return <span className={className}>𝕏</span>
case "google_doc":
return <GoogleDocs {...iconProps} />
case "google_sheet":
return <GoogleSheets {...iconProps} />
case "google_slide":
return <GoogleSlides {...iconProps} />
case "google_drive":
return <GoogleDrive {...iconProps} />
case "notion":
case "notion_doc":
return <NotionDoc {...iconProps} />
case "word":
case "microsoft_word":
return <MicrosoftWord {...iconProps} />
case "excel":
case "microsoft_excel":
return <MicrosoftExcel {...iconProps} />
case "powerpoint":
case "microsoft_powerpoint":
return <MicrosoftPowerpoint {...iconProps} />
case "onenote":
case "microsoft_onenote":
return <MicrosoftOneNote {...iconProps} />
case "onedrive":
return <OneDrive {...iconProps} />
case "pdf":
return <PDFIcon className={className} />
case "youtube":
case "video":
return <YouTubeIcon className={className} />
default:
return <TextDocumentIcon className={className} />
}
}

View file

@ -0,0 +1,322 @@
import { cn } from "@lib/utils"
import { Tabs, TabsList, TabsTrigger } from "@ui/components/tabs"
export interface MemoryEntry {
id: string
memory: string
title?: string
url?: string
version: number
isForgotten: boolean
forgetAfter: string | null
isLatest: boolean
isStatic: boolean
}
function VersionStatus({
status,
}: {
status: "latest" | "static" | "expiring" | "forgotten"
}) {
return (
<>
{
{
latest: (
<div className="text-[#05A376] text-[10px] font-medium flex items-center gap-1">
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<title>Latest</title>
<g opacity="0.6">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M5.00069 0L9.33082 2.5V7.5L5.00069 10L0.670563 7.5V2.5L5.00069 0ZM7.5008 2.5H5.0008H2.50069L2.5008 7.5H7.5008V5V2.5Z"
fill="#00FFA9"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M5.0008 2.5H2.50069L2.5008 7.5H7.5008V5C6.12009 5 5.0008 3.88071 5.0008 2.5Z"
fill="#005236"
/>
<path
d="M7.5008 2.5H5.0008C5.0008 3.88071 6.12009 5 7.5008 5V2.5Z"
fill="#00FFA9"
/>
<path
d="M9.08072 2.64453V7.35449L5.00064 9.71094L0.920563 7.35449V2.64453L5.00064 0.288086L9.08072 2.64453ZM2.25064 2.25V7.75H7.75064V2.25H2.25064ZM4.76334 2.75C4.88226 4.0691 5.93157 5.11728 7.25064 5.23633V7.25H2.75064V2.75H4.76334ZM7.25064 2.75V4.73438C6.20794 4.61894 5.3806 3.79274 5.26529 2.75H7.25064Z"
stroke="#00FFA9"
strokeWidth="0.5"
/>
</g>
</svg>
Latest
</div>
),
static: (
<div className="text-[#369BFD] text-[10px] font-medium flex items-center gap-1">
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<title>Static</title>
<circle cx="5.00061" cy="5.00073" r="1.25" fill="#369BFD" />
<circle
cx="5.00061"
cy="5.00073"
r="0.833333"
stroke="#369BFD"
strokeOpacity="0.5"
strokeWidth="0.833333"
/>
<circle
cx="5.00057"
cy="5.00081"
r="2.91667"
stroke="#369BFD"
strokeOpacity="0.5"
strokeWidth="0.833333"
/>
<circle
cx="5"
cy="5"
r="4.58333"
stroke="#369BFD"
strokeOpacity="0.2"
strokeWidth="0.833333"
/>
</svg>
Static
</div>
),
expiring: (
<div className="text-[#9D6510] text-[10px] font-medium">
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<title>Expiring</title>
<g opacity="0.6">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2.50066 2.5H7.50077V7.5H2.50077L2.50066 2.5Z"
fill="#4D2E00"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M5.00066 0L9.33078 2.5V7.5L5.00066 10L0.670532 7.5V2.5L5.00066 0ZM7.50077 2.5H2.50066L2.50077 7.5H7.50077V2.5Z"
fill="#FE9900"
/>
<path
d="M9.08069 2.64453V7.35449L5.00061 9.71094L0.920532 7.35449V2.64453L5.00061 0.288086L9.08069 2.64453ZM2.25061 2.25V7.75H7.75061V2.25H2.25061ZM7.25061 2.75V7.25H2.75061V2.75H7.25061Z"
stroke="#FE9900"
strokeWidth="0.5"
/>
</g>
</svg>
Expiring
</div>
),
forgotten: (
<div className="text-[#9C4044] text-[10px] font-medium">
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<title>Forgotten</title>
<path
opacity="0.6"
d="M9.08008 2.64453V7.35449L5 9.71094L0.919922 7.35449V2.64453L5 0.288086L9.08008 2.64453Z"
fill="#60272C"
stroke="#FF6467"
strokeWidth="0.5"
/>
<path
d="M2.08333 2.08341L7.91677 7.91685M7.91667 2.08341L2.08333 7.91675"
stroke="#9C4044"
strokeWidth="0.5"
/>
</svg>
Forgotten
</div>
),
}[status]
}
</>
)
}
export function GraphListMemories({
memoryEntries,
}: {
memoryEntries: MemoryEntry[]
}) {
return (
<div
id="document-memories"
className="relative flex-1 px-3 pt-3 rounded-[14px] flex flex-col overflow-hidden shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]"
style={{
backgroundImage: "url('/dot-pattern.svg')",
backgroundRepeat: "repeat",
backgroundSize: "369px 371px",
}}
>
<Tabs defaultValue="list">
<TabsList className="rounded-full border border-[#161F2C] h-11! z-10!">
<TabsTrigger
value="graph"
className={cn(
"group rounded-full data-[state=active]:bg-[#00173C]! dark:data-[state=active]:border-[#2261CA33]! px-4 py-4",
)}
>
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<title>Graph</title>
<mask
id="mask0_891_45456"
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="14"
height="14"
>
<rect width="14" height="14" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_891_45456)">
<path
d="M7.00057 12.8334C6.51446 12.8334 6.10126 12.6632 5.76099 12.3229C5.42071 11.9827 5.25057 11.5695 5.25057 11.0834C5.25057 11.0347 5.253 10.9813 5.25786 10.9229C5.26272 10.8646 5.27001 10.8111 5.27974 10.7625L4.06932 10.0771C3.91376 10.2132 3.73876 10.3177 3.54432 10.3906C3.34988 10.4636 3.14085 10.5 2.91724 10.5C2.43113 10.5 2.01793 10.3299 1.67765 9.9896C1.33738 9.64933 1.16724 9.23613 1.16724 8.75002C1.16724 8.26391 1.33738 7.85071 1.67765 7.51044C2.01793 7.17016 2.43113 7.00002 2.91724 7.00002C3.15057 7.00002 3.36932 7.04377 3.57349 7.13127C3.77765 7.21877 3.96238 7.3403 4.12765 7.49585L5.86307 6.62085C5.8339 6.39724 5.84606 6.17849 5.89953 5.9646C5.953 5.75071 6.04779 5.55141 6.1839 5.36669L5.68807 4.60835C5.62001 4.6278 5.54953 4.64238 5.47661 4.6521C5.40369 4.66183 5.32835 4.66669 5.25057 4.66669C4.76446 4.66669 4.35126 4.49655 4.01099 4.15627C3.67071 3.81599 3.50057 3.4028 3.50057 2.91669C3.50057 2.43058 3.67071 2.01738 4.01099 1.6771C4.35126 1.33683 4.76446 1.16669 5.25057 1.16669C5.73668 1.16669 6.14988 1.33683 6.49015 1.6771C6.83043 2.01738 7.00057 2.43058 7.00057 2.91669C7.00057 3.11113 6.96897 3.29828 6.90578 3.47815C6.84258 3.65801 6.75751 3.82085 6.65057 3.96669L7.16099 4.72502C7.23876 4.70558 7.31168 4.69099 7.37974 4.68127C7.44779 4.67155 7.52071 4.66669 7.59849 4.66669C7.76376 4.66669 7.91932 4.68613 8.06515 4.72502C8.21099 4.76391 8.35196 4.82224 8.48807 4.90002L9.45057 4.11252C9.41168 4.0153 9.38251 3.91565 9.36307 3.81356C9.34363 3.71148 9.3339 3.60696 9.3339 3.50002C9.3339 3.01391 9.50404 2.60071 9.84432 2.26044C10.1846 1.92016 10.5978 1.75002 11.0839 1.75002C11.57 1.75002 11.9832 1.92016 12.3235 2.26044C12.6638 2.60071 12.8339 3.01391 12.8339 3.50002C12.8339 3.98613 12.6638 4.39933 12.3235 4.7396C11.9832 5.07988 11.57 5.25002 11.0839 5.25002C10.9186 5.25002 10.7631 5.22815 10.6172 5.1844C10.4714 5.14065 10.3304 5.07988 10.1943 5.0021L9.23182 5.80419C9.27071 5.90141 9.29988 6.00106 9.31932 6.10315C9.33876 6.20523 9.34849 6.30974 9.34849 6.41669C9.34849 6.9028 9.17835 7.31599 8.83807 7.65627C8.49779 7.99655 8.0846 8.16669 7.59849 8.16669C7.36515 8.16669 7.14397 8.12294 6.93494 8.03544C6.72592 7.94794 6.53876 7.82641 6.37349 7.67085L4.65265 8.53127C4.6721 8.61877 4.67939 8.70627 4.67453 8.79377C4.66967 8.88127 4.65751 8.96877 4.63807 9.05627L5.86307 9.75627C6.01863 9.62016 6.19119 9.51565 6.38078 9.44273C6.57036 9.36981 6.77696 9.33335 7.00057 9.33335C7.48668 9.33335 7.89988 9.50349 8.24015 9.84377C8.58043 10.184 8.75057 10.5972 8.75057 11.0834C8.75057 11.5695 8.58043 11.9827 8.24015 12.3229C7.89988 12.6632 7.48668 12.8334 7.00057 12.8334ZM2.91724 9.33335C3.08251 9.33335 3.22106 9.27745 3.33286 9.16565C3.44467 9.05384 3.50057 8.9153 3.50057 8.75002C3.50057 8.58474 3.44467 8.4462 3.33286 8.3344C3.22106 8.22259 3.08251 8.16669 2.91724 8.16669C2.75196 8.16669 2.61342 8.22259 2.50161 8.3344C2.38981 8.4462 2.3339 8.58474 2.3339 8.75002C2.3339 8.9153 2.38981 9.05384 2.50161 9.16565C2.61342 9.27745 2.75196 9.33335 2.91724 9.33335ZM5.25057 3.50002C5.41585 3.50002 5.55439 3.44412 5.66619 3.33231C5.778 3.22051 5.8339 3.08196 5.8339 2.91669C5.8339 2.75141 5.778 2.61287 5.66619 2.50106C5.55439 2.38926 5.41585 2.33335 5.25057 2.33335C5.08529 2.33335 4.94675 2.38926 4.83494 2.50106C4.72314 2.61287 4.66724 2.75141 4.66724 2.91669C4.66724 3.08196 4.72314 3.22051 4.83494 3.33231C4.94675 3.44412 5.08529 3.50002 5.25057 3.50002ZM7.00057 11.6667C7.16585 11.6667 7.30439 11.6108 7.41619 11.499C7.528 11.3872 7.5839 11.2486 7.5839 11.0834C7.5839 10.9181 7.528 10.7795 7.41619 10.6677C7.30439 10.5559 7.16585 10.5 7.00057 10.5C6.83529 10.5 6.69675 10.5559 6.58494 10.6677C6.47314 10.7795 6.41724 10.9181 6.41724 11.0834C6.41724 11.2486 6.47314 11.3872 6.58494 11.499C6.69675 11.6108 6.83529 11.6667 7.00057 11.6667ZM7.5839 7.00002C7.74918 7.00002 7.88772 6.94412 7.99953 6.83231C8.11133 6.72051 8.16724 6.58196 8.16724 6.41669C8.16724 6.25141 8.11133 6.11287 7.99953 6.00106C7.88772 5.88926 7.74918 5.83335 7.5839 5.83335C7.41862 5.83335 7.28008 5.88926 7.16828 6.00106C7.05647 6.11287 7.00057 6.25141 7.00057 6.41669C7.00057 6.58196 7.05647 6.72051 7.16828 6.83231C7.28008 6.94412 7.41862 7.00002 7.5839 7.00002ZM11.0839 4.08335C11.2492 4.08335 11.3877 4.02745 11.4995 3.91565C11.6113 3.80384 11.6672 3.6653 11.6672 3.50002C11.6672 3.33474 11.6113 3.1962 11.4995 3.0844C11.3877 2.97259 11.2492 2.91669 11.0839 2.91669C10.9186 2.91669 10.7801 2.97259 10.6683 3.0844C10.5565 3.1962 10.5006 3.33474 10.5006 3.50002C10.5006 3.6653 10.5565 3.80384 10.6683 3.91565C10.7801 4.02745 10.9186 4.08335 11.0839 4.08335Z"
className="fill-[#737373] group-hover:fill-white group-data-[state=active]:fill-white"
/>
</g>
</svg>
<p className="group-hover:text-white group-data-[state=active]:text-white">
Graph
</p>
</TabsTrigger>
<TabsTrigger
value="list"
className={cn(
"group rounded-full dark:data-[state=active]:bg-[#00173C]! dark:data-[state=active]:border-[#2261CA33]! px-6 py-[7px] align-middle items-center",
)}
>
<svg
width="10"
height="12"
viewBox="0 0 10 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="w-3.5! h-3.5!"
>
<title>List</title>
<path
d="M0.00153996 1.78136H0.0032201C0.00332596 1.86499 0.0363955 1.94522 0.095258 2.00463C0.15412 2.06404 0.23403 2.09786 0.31766 2.09874L8.76792 2.09902C8.85189 2.09861 8.93229 2.06485 8.99154 2.00536C9.0508 1.94586 9.08411 1.86533 9.08418 1.78136H9.08558V0.3171C9.08539 0.23296 9.05185 0.152327 8.9923 0.0928833C8.93275 0.0334395 8.85206 3.68622e-05 8.76792 0H0.31766C0.233423 3.71008e-05 0.152646 0.0335166 0.0930814 0.0930814C0.0335167 0.152646 3.71008e-05 0.233423 0 0.31766C0 0.32312 0.00139988 0.3283 0.00167988 0.33376L0.00153996 1.78136ZM8.76778 4.49314H0.31766C0.233423 4.49318 0.152646 4.52666 0.0930814 4.58622C0.0335167 4.64579 3.71008e-05 4.72656 0 4.8108C0 4.81626 0.00139988 4.82144 0.00167988 4.8269V6.2745H0.00336002C0.00346588 6.35813 0.0365354 6.43836 0.0953979 6.49777C0.15426 6.55718 0.234171 6.591 0.3178 6.59188L8.76806 6.59216C8.85203 6.59175 8.93243 6.55799 8.99168 6.4985C9.05094 6.439 9.08425 6.35847 9.08432 6.2745H9.08572V4.81024C9.08553 4.72605 9.05195 4.64538 8.99234 4.58592C8.93273 4.52647 8.85197 4.4931 8.76778 4.49314ZM8.76778 8.98614H0.31766C0.233423 8.98618 0.152646 9.01966 0.0930814 9.07922C0.0335167 9.13879 3.71008e-05 9.21956 0 9.3038C0 9.30926 0.00139988 9.31444 0.00167988 9.3199V10.7675H0.00336002C0.00346588 10.8511 0.0365354 10.9314 0.0953979 10.9908C0.15426 11.0502 0.234171 11.084 0.3178 11.0849L8.76806 11.0852C8.85205 11.0848 8.93247 11.051 8.99173 10.9914C9.051 10.9319 9.08428 10.8514 9.08432 10.7674H9.08572V9.3031C9.0855 9.21894 9.0519 9.1383 8.99229 9.07887C8.93269 9.01945 8.85194 8.9861 8.76778 8.98614Z"
className="fill-[#737373] group-hover:fill-white group-data-[state=active]:fill-white"
/>
</svg>
<p className="group-hover:text-white group-data-[state=active]:text-white">
List
</p>
</TabsTrigger>
</TabsList>
</Tabs>
<div className="grid grid-cols-2 gap-2 pt-3 overflow-y-auto pr-1 scrollbar-thin">
{memoryEntries.map((memory, idx) => {
const isClickable =
memory.url &&
(memory.url.startsWith("http://") ||
memory.url.startsWith("https://"))
const status = memory.isForgotten
? "forgotten"
: memory.forgetAfter
? "expiring"
: memory.isStatic
? "static"
: "latest"
const content = (
<div className="">
<div className="bg-[#060D17] p-2 px-[10px] rounded-[10px] m-[2px]">
{memory.title && (
<div className="text-xs text-[#525D6E] line-clamp-2">
{memory.title}
</div>
)}
{memory.memory && (
<div className="text-xs text-[#525D6E]/80 line-clamp-2">
{memory.memory}
</div>
)}
{memory.url && (
<div className="text-xs text-[#525D6E] truncate">
{memory.url}
</div>
)}
</div>
<div className="px-[10px] py-1 flex items-center justify-between">
<div
className="text-[10px] inline-block bg-clip-text text-transparent font-medium"
style={{
background:
"linear-gradient(94deg, #369BFD 4.8%, #36FDFD 77.04%, #36FDB5 143.99%)",
backgroundClip: "text",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
}}
>
v{memory.version}
</div>
<VersionStatus status={status} />
</div>
</div>
)
if (isClickable) {
return (
<a
className="block p-2 bg-[#0C1829]/50 rounded-md border border-[#525D6E]/20 hover:bg-[#0C1829]/70 transition-colors cursor-pointer"
href={memory.url}
key={memory.id || idx}
rel="noopener noreferrer"
target="_blank"
>
{content}
</a>
)
}
return (
<div
className={cn("bg-[#0C1829] rounded-xl")}
key={memory.id || idx}
>
{content}
</div>
)
})}
</div>
</div>
)
}

View file

@ -0,0 +1,143 @@
"use client"
import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import { ArrowUpRightIcon, XIcon } from "lucide-react"
import type { z } from "zod"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { cn } from "@lib/utils"
import dynamic from "next/dynamic"
import { Title } from "./title"
import { Summary as DocumentSummary } from "./summary"
import { dmSansClassName } from "@/utils/fonts"
import { GraphListMemories, type MemoryEntry } from "./graph-list-memories"
import { YoutubeVideo } from "./content/yt-video"
import { TweetContent } from "./content/tweet"
import { isTwitterUrl } from "@/utils/url-helpers"
import { NotionDoc } from "./content/notion-doc"
// Dynamically importing to prevent DOMMatrix error
const PdfViewer = dynamic(
() => import("./content/pdf").then((mod) => ({ default: mod.PdfViewer })),
{
ssr: false,
loading: () => (
<div className="flex items-center justify-center h-full text-gray-400">
Loading PDF viewer...
</div>
),
},
) as typeof import("./content/pdf").PdfViewer
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
interface DocumentModalProps {
document: DocumentWithMemories | null
isOpen: boolean
onClose: () => void
}
export function DocumentModal({
document: _document,
isOpen,
onClose,
}: DocumentModalProps) {
console.log(_document)
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent
className={cn(
"w-[80%]! max-w-[1158px]! h-[86%]! max-h-[684px]! p-0 border-none bg-[#1B1F24] flex flex-col px-4 pt-3 pb-4 gap-3 rounded-[22px]",
dmSansClassName(),
)}
style={{
boxShadow:
"0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
}}
showCloseButton={false}
>
<DialogTitle className="sr-only">
{_document?.title} - Document
</DialogTitle>
<div className="flex justify-between h-fit">
<Title
title={_document?.title}
documentType={_document?.type ?? "text"}
url={_document?.url}
/>
<div className="flex items-center gap-2">
{_document?.url && (
<a
href={_document.url}
target="_blank"
rel="noopener noreferrer"
className="line-clamp-1 px-3 py-2 flex items-center gap-1 bg-[#0D121A] rounded-full shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]"
>
Visit source
<ArrowUpRightIcon className="w-4 h-4 text-[#737373]" />
</a>
)}
<DialogPrimitive.Close
className="bg-[#0D121A] w-7 h-7 flex items-center justify-center focus:ring-ring rounded-full transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]"
data-slot="dialog-close"
>
<XIcon stroke="#737373" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</div>
</div>
<div className="flex-1 grid grid-cols-[2fr_1fr] gap-3 overflow-hidden min-h-0">
<div
id="document-preview"
className={cn(
"bg-[#14161A] rounded-[14px] overflow-hidden flex flex-col shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]",
)}
>
{(_document?.type === "tweet" ||
(_document?.url && isTwitterUrl(_document.url))) && (
<TweetContent
url={_document?.url}
tweetMetadata={
_document?.metadata?.sm_internal_twitter_metadata
}
/>
)}
{_document?.type === "text" && (
<div className="p-4 overflow-y-auto flex-1">
{_document.content}
</div>
)}
{_document?.type === "pdf" && <PdfViewer url={_document.url} />}
{_document?.type === "notion_doc" && (
<NotionDoc content={_document.content ?? ""} />
)}
{_document?.url?.includes("youtube.com") && (
<YoutubeVideo url={_document.url} />
)}
</div>
<div
id="document-memories-summary"
className={cn(
"gap-3 flex flex-col overflow-hidden",
dmSansClassName(),
)}
>
{_document?.summary && (
<DocumentSummary
memoryEntries={_document.memoryEntries}
summary={_document.summary}
createdAt={_document.createdAt}
/>
)}
{_document?.memoryEntries && _document.memoryEntries.length > 0 && (
<GraphListMemories
memoryEntries={_document.memoryEntries as MemoryEntry[]}
/>
)}
</div>
</div>
</DialogContent>
</Dialog>
)
}

View file

@ -0,0 +1,84 @@
import { cn } from "@lib/utils"
import { SyncLogoIcon } from "@ui/assets/icons"
function CalendarIcon() {
return (
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<title>Calendar Icon</title>
<path
d="M1.33333 13.3333C0.966667 13.3333 0.652778 13.2028 0.391667 12.9417C0.130556 12.6806 0 12.3667 0 12V2.66667C0 2.3 0.130556 1.98611 0.391667 1.725C0.652778 1.46389 0.966667 1.33333 1.33333 1.33333H2V0H3.33333V1.33333H8.66667V0H10V1.33333H10.6667C11.0333 1.33333 11.3472 1.46389 11.6083 1.725C11.8694 1.98611 12 2.3 12 2.66667V6H10.6667V5.33333H1.33333V12H6V13.3333H1.33333ZM1.33333 4H10.6667V2.66667H1.33333V4ZM7.33333 13.3333V11.2833L11.0167 7.61667C11.1167 7.51667 11.2278 7.44444 11.35 7.4C11.4722 7.35556 11.5944 7.33333 11.7167 7.33333C11.85 7.33333 11.9778 7.35833 12.1 7.40833C12.2222 7.45833 12.3333 7.53333 12.4333 7.63333L13.05 8.25C13.1389 8.35 13.2083 8.46111 13.2583 8.58333C13.3083 8.70555 13.3333 8.82778 13.3333 8.95C13.3333 9.07222 13.3111 9.19722 13.2667 9.325C13.2222 9.45278 13.15 9.56667 13.05 9.66667L9.38333 13.3333H7.33333ZM8.33333 12.3333H8.96667L10.9833 10.3L10.6833 9.98333L10.3667 9.68333L8.33333 11.7V12.3333ZM10.6833 9.98333L10.3667 9.68333L10.9833 10.3L10.6833 9.98333Z"
fill="#737373"
/>
</svg>
)
}
export function Summary({
memoryEntries,
summary,
createdAt,
}: {
memoryEntries: any[]
summary: string
createdAt: Date
}) {
return (
<div
id="document-summary"
className="bg-[#14161A] p-3 rounded-[14px] space-y-4 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]"
>
<div className="flex items-center justify-between">
<p className="text-[16px] font-semibold text-[#FAFAFA] line-clamp-1 leading-[125%]">
Summary
</p>
<div className="text-[#737373] text-[10px] leading-[150%]">
powered by supermemory
</div>
</div>
<div className="text-[14px] text-[#FAFAFA] leading-[1.4] max-h-[117.6px] overflow-y-auto scrollbar-thin">
{summary}
</div>
<div
className={cn(
"flex items-center",
memoryEntries.length > 0 ? "justify-between" : "justify-end",
)}
>
{memoryEntries.length > 0 && (
<p
className={cn(
"text-[#369BFD] line-clamp-1 flex items-center gap-1.5",
)}
style={{
background:
"linear-gradient(94deg, #369BFD 4.8%, #36FDFD 77.04%, #36FDB5 143.99%)",
backgroundClip: "text",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
}}
>
<SyncLogoIcon className="w-[17.1px] h-[13.87px]" />
{memoryEntries.length}{" "}
{memoryEntries.length === 1 ? "memory" : "memories"}
</p>
)}
<p
className={cn("text-[#737373] line-clamp-1 flex items-center gap-1")}
>
<CalendarIcon />
{new Date(createdAt).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
</p>
</div>
</div>
)
}

View file

@ -0,0 +1,55 @@
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/utils/fonts"
import type { DocumentTypeEnum } from "@repo/validation/schemas"
import type { z } from "zod"
import { getDocumentIcon } from "@/components/new/document-modal/document-icon"
type DocumentType = z.infer<typeof DocumentTypeEnum>
function getFileExtension(documentType: DocumentType): string | null {
switch (documentType) {
case "pdf":
return ".pdf"
default:
return null
}
}
export function Title({
title,
documentType,
url,
}: {
title: string | null | undefined
documentType: DocumentType
url?: string | null
}) {
const extension = getFileExtension(documentType)
return (
<div
className={cn(
dmSansClassName(),
"text-[16px] font-semibold text-[#FAFAFA] line-clamp-1 leading-[125%] flex items-center gap-3",
)}
>
<div className="pl-1 flex items-center gap-1 w-5 h-5">
{getDocumentIcon(
documentType as DocumentType,
"w-5 h-5",
undefined,
url ?? undefined,
)}
{extension && (
<p
className={cn(dmSansClassName(), "text-[12px] font-semibold")}
style={{ color: "#FF7673" }}
>
{extension}
</p>
)}
</div>
{title || "Untitled Document"}
</div>
)
}

View file

@ -0,0 +1,225 @@
"use client"
import { Logo } from "@ui/assets/Logo"
import { Avatar, AvatarFallback, AvatarImage } from "@ui/components/avatar"
import { useAuth } from "@lib/auth-context"
import { useEffect, useState } from "react"
import {
ChevronsLeftRight,
LayoutGridIcon,
Plus,
SearchIcon,
FolderIcon,
LogOut,
Settings,
} from "lucide-react"
import { Button } from "@ui/components/button"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/utils/fonts"
import { Tabs, TabsList, TabsTrigger } from "@ui/components/tabs"
import { useProjectName } from "@/hooks/use-project-name"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@ui/components/dropdown-menu"
import { useQuery } from "@tanstack/react-query"
import { $fetch } from "@repo/lib/api"
import { authClient } from "@lib/auth"
import { DEFAULT_PROJECT_ID } from "@repo/lib/constants"
import { useProjectMutations } from "@/hooks/use-project-mutations"
import { useProject } from "@/stores"
import { useRouter } from "next/navigation"
import type { Project } from "@repo/lib/types"
interface HeaderProps {
onAddMemory?: () => void
onOpenMCP?: () => void
}
export function Header({ onAddMemory, onOpenMCP }: HeaderProps) {
const { user } = useAuth()
const [name, setName] = useState<string>("")
const projectName = useProjectName()
const { selectedProject } = useProject()
const { switchProject } = useProjectMutations()
const router = useRouter()
const { data: projects = [] } = useQuery({
queryKey: ["projects"],
queryFn: async () => {
const response = await $fetch("@get/projects")
if (response.error) {
throw new Error(response.error?.message || "Failed to load projects")
}
return response.data?.projects || []
},
staleTime: 30 * 1000,
})
useEffect(() => {
const storedName =
localStorage.getItem("username") || localStorage.getItem("userName") || ""
setName(storedName)
}, [])
const userName = name ? `${name.split(" ")[0]}'s` : "My"
return (
<div className="flex p-4 justify-between items-center">
<div className="flex items-center justify-center gap-4 z-10!">
<div className="flex items-center">
<Logo className="h-7" />
{name && (
<div className="flex flex-col items-start justify-center ml-2">
<p className="text-[#8B8B8B] text-[11px] leading-tight">
{userName}
</p>
<p className="text-white font-bold text-xl leading-none -mt-1">
supermemory
</p>
</div>
)}
</div>
<div className="self-stretch w-px bg-[#FFFFFF33]" />
<div className="flex items-center gap-2">
<p>📁 {projectName}</p>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="cursor-pointer hover:opacity-70 transition-opacity"
aria-label="Change project"
>
<ChevronsLeftRight className="size-4 rotate-90" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuItem
onClick={() => switchProject(DEFAULT_PROJECT_ID)}
className={cn(
"cursor-pointer",
selectedProject === DEFAULT_PROJECT_ID && "bg-accent",
)}
>
<FolderIcon className="h-3.5 w-3.5 mr-2" />
<span className="text-sm">Default Project</span>
</DropdownMenuItem>
{projects
.filter((p: Project) => p.containerTag !== DEFAULT_PROJECT_ID)
.map((project: Project) => (
<DropdownMenuItem
key={project.id}
onClick={() => switchProject(project.containerTag)}
className={cn(
"cursor-pointer",
selectedProject === project.containerTag && "bg-accent",
)}
>
<FolderIcon className="h-3.5 w-3.5 mr-2 opacity-70" />
<span className="text-sm truncate">{project.name}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<Tabs defaultValue="grid">
<TabsList className="rounded-full border border-[#161F2C] h-11! z-10!">
<TabsTrigger
value="grid"
className={cn(
"rounded-full data-[state=active]:bg-[#00173C]! dark:data-[state=active]:border-[#2261CA33]! px-4 py-4",
dmSansClassName(),
)}
>
<LayoutGridIcon className="size-4" />
Grid
</TabsTrigger>
<TabsTrigger
value="graph"
className={cn(
"rounded-full dark:data-[state=active]:bg-[#00173C]! dark:data-[state=active]:border-[#2261CA33]! px-4 py-4",
dmSansClassName(),
)}
>
<LayoutGridIcon className="size-4" />
Graph
</TabsTrigger>
</TabsList>
</Tabs>
<div className="flex items-center gap-2 z-10!">
<Button
variant="headers"
className="rounded-full text-base gap-2 h-10!"
onClick={onAddMemory}
>
<div className="flex items-center gap-2">
<Plus className="size-4" />
Add memory
</div>
<span
className={cn(
"bg-[#21212180] border border-[#73737333] text-[#737373] rounded-sm size-4 text-[10px] flex items-center justify-center",
dmSansClassName(),
)}
>
C
</span>
</Button>
<Button
variant="headers"
className="rounded-full text-base gap-2 h-10!"
onClick={onOpenMCP}
>
<div className="flex items-center gap-2">MCP</div>
</Button>
<Button
variant="headers"
className="rounded-full text-base gap-2 h-10!"
>
<SearchIcon className="size-4" />
<span className="bg-[#21212180] border border-[#73737333] text-[#737373] rounded-sm text-[10px] flex items-center justify-center gap-0.5 px-1">
<svg
className="size-[7.5px]"
viewBox="0 0 9 9"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<title>Search Icon</title>
<path
d="M6.66663 0.416626C6.33511 0.416626 6.01716 0.548322 5.78274 0.782743C5.54832 1.01716 5.41663 1.33511 5.41663 1.66663V6.66663C5.41663 6.99815 5.54832 7.31609 5.78274 7.55051C6.01716 7.78493 6.33511 7.91663 6.66663 7.91663C6.99815 7.91663 7.31609 7.78493 7.55051 7.55051C7.78493 7.31609 7.91663 6.99815 7.91663 6.66663C7.91663 6.33511 7.78493 6.01716 7.55051 5.78274C7.31609 5.54832 6.99815 5.41663 6.66663 5.41663H1.66663C1.33511 5.41663 1.01716 5.54832 0.782743 5.78274C0.548322 6.01716 0.416626 6.33511 0.416626 6.66663C0.416626 6.99815 0.548322 7.31609 0.782743 7.55051C1.01716 7.78493 1.33511 7.91663 1.66663 7.91663C1.99815 7.91663 2.31609 7.78493 2.55051 7.55051C2.78493 7.31609 2.91663 6.99815 2.91663 6.66663V1.66663C2.91663 1.33511 2.78493 1.01716 2.55051 0.782743C2.31609 0.548322 1.99815 0.416626 1.66663 0.416626C1.33511 0.416626 1.01716 0.548322 0.782743 0.782743C0.548322 1.01716 0.416626 1.33511 0.416626 1.66663C0.416626 1.99815 0.548322 2.31609 0.782743 2.55051C1.01716 2.78493 1.33511 2.91663 1.66663 2.91663H6.66663C6.99815 2.91663 7.31609 2.78493 7.55051 2.55051C7.78493 2.31609 7.91663 1.99815 7.91663 1.66663C7.91663 1.33511 7.78493 1.01716 7.55051 0.782743C7.31609 0.548322 6.99815 0.416626 6.66663 0.416626Z"
stroke="#737373"
strokeWidth="0.833333"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<span className={cn(dmSansClassName())}>K</span>
</span>
</Button>
{user && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Avatar className="border border-border h-8 w-8 md:h-10 md:w-10 cursor-pointer">
<AvatarImage src={user?.image ?? ""} />
<AvatarFallback>{user?.name?.charAt(0)}</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => router.push("/new/settings")}>
<Settings className="h-4 w-4" />
Settings
</DropdownMenuItem>
<DropdownMenuItem onClick={() => authClient.signOut()}>
<LogOut className="h-4 w-4" />
Logout
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,70 @@
import { dmSans125ClassName, dmSansClassName } from "@/utils/fonts"
import { Dialog, DialogContent, DialogFooter } from "@repo/ui/components/dialog"
import { cn } from "@lib/utils"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { ChevronsUpDownIcon, XIcon } from "lucide-react"
import { Button } from "@ui/components/button"
import { MCPSteps } from "./mcp-detail-view"
export function MCPModal({
isOpen,
onClose,
}: {
isOpen: boolean
onClose: () => void
}) {
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent
className={cn(
"w-[80%]! max-w-[900px]! h-[80%]! max-h-[375px]! border-none bg-[#1B1F24] flex flex-col p-4 gap-3 rounded-[22px]",
dmSansClassName(),
)}
style={{
boxShadow:
"0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
}}
showCloseButton={false}
>
<div className="flex justify-between h-fit">
<div className="pl-1 space-y-1">
<p className={cn("font-semibold", dmSans125ClassName())}>
Connect your AI to Supermemory
</p>
<p className={cn("text-[#737373] font-medium")}>
Let your AI create and use your memories via MCP. Learn more
</p>
</div>
<div className="flex items-center gap-2">
<DialogPrimitive.Close
className="bg-[#0D121A] w-7 h-7 flex items-center justify-center focus:ring-ring rounded-full transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]"
data-slot="dialog-close"
>
<XIcon stroke="#737373" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</div>
</div>
<div className="w-full px-4 py-4 rounded-[14px] bg-[#14161A] shadow-inside-out overflow-y-auto">
<MCPSteps variant="embedded" />
</div>
<DialogFooter className="justify-between!">
<div className="flex items-center gap-2">
<Button variant="insideOut">
My Space <ChevronsUpDownIcon className="size-4" color="#737373" />
</Button>
<Button
variant="ghost"
className="text-[#737373] cursor-pointer rounded-full"
>
Migrate from MCP v1
</Button>
</div>
<Button variant="insideOut" className="px-6 py-[10px]">
Done
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View file

@ -0,0 +1,629 @@
"use client"
import { useState, useEffect } from "react"
import { Button } from "@ui/components/button"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@ui/components/select"
import { CircleCheckIcon, CopyIcon, Check } from "lucide-react"
import Image from "next/image"
import { toast } from "sonner"
import { analytics } from "@/lib/analytics"
import { cn } from "@lib/utils"
import { dmMonoClassName, dmSansClassName } from "@/utils/fonts"
import { SyncLogoIcon } from "@ui/assets/icons"
const clients = {
cursor: "Cursor",
claude: "Claude Desktop",
vscode: "VSCode",
cline: "Cline",
"gemini-cli": "Gemini CLI",
"claude-code": "Claude Code",
"mcp-url": "MCP URL",
"roo-cline": "Roo Cline",
witsy: "Witsy",
enconvo: "Enconvo",
} as const
interface MCPStepsProps {
variant?: "full" | "embedded"
}
export function MCPSteps({ variant = "full" }: MCPStepsProps) {
const [selectedClient, setSelectedClient] = useState<
keyof typeof clients | null
>(null)
const [selectedProject] = useState<string>("sm_project_default")
const [mcpUrlTab, setMcpUrlTab] = useState<"oneClick" | "manual">("oneClick")
const [isCopied, setIsCopied] = useState(false)
const [activeStep, setActiveStep] = useState<1 | 2 | 3>(1)
useEffect(() => {
analytics.mcpViewOpened()
}, [])
function generateInstallCommand() {
if (!selectedClient) return ""
let command = `npx -y install-mcp@latest https://api.supermemory.ai/mcp --client ${selectedClient} --oauth=yes`
const projectIdForCommand = selectedProject.replace(/^sm_project_/, "")
command += ` --project ${projectIdForCommand}`
return command
}
const copyToClipboard = () => {
const command = generateInstallCommand()
navigator.clipboard.writeText(command)
analytics.mcpInstallCmdCopied()
setIsCopied(true)
setActiveStep(3)
setTimeout(() => setIsCopied(false), 2000)
}
const isEmbedded = variant === "embedded"
return (
<div
className={cn(
"w-full relative",
isEmbedded ? "h-full overflow-y-auto" : "max-w-2xl",
)}
>
<div
className="absolute left-4 top-0 w-px bg-[#1E293B] z-10"
style={{ height: activeStep === 3 ? isEmbedded ? "100%" : "calc(100% - 4rem)" : "100%" }}
/>
<div className="flex items-start space-x-4 z-20">
<button
type="button"
className={cn(
"rounded-full w-8 h-8 flex items-center justify-center text-sm font-medium shrink-0 z-20 text-white",
selectedClient && "cursor-pointer hover:bg-[#1a2530]",
!selectedClient && "cursor-default",
activeStep === 1
? "border border-[#15233C] bg-[#08142D]"
: "bg-[#161F2B] ",
)}
onClick={() => {
if (selectedClient) {
setSelectedClient(null)
setActiveStep(1)
}
}}
onKeyDown={(e) => {
if (selectedClient && (e.key === "Enter" || e.key === " ")) {
e.preventDefault()
setSelectedClient(null)
setActiveStep(1)
}
}}
disabled={!selectedClient}
>
<span
className="text-lg"
style={
activeStep === 1
? {
background:
"linear-gradient(94deg, #369BFD 4.8%, #36FDFD 77.04%, #36FDB5 143.99%)",
backgroundClip: "text",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
}
: undefined
}
>
1
</span>
</button>
<div className="flex-1 mb-4">
<div className="flex gap-4 mb-4">
<button
type="button"
className={cn(
"text-white text-lg font-medium text-center",
selectedClient && "cursor-pointer hover:opacity-80",
!selectedClient && "cursor-default",
)}
onClick={() => {
if (selectedClient) {
setSelectedClient(null)
setActiveStep(1)
}
}}
onKeyDown={(e) => {
if (selectedClient && (e.key === "Enter" || e.key === " ")) {
e.preventDefault()
setSelectedClient(null)
setActiveStep(1)
}
}}
disabled={!selectedClient}
style={
activeStep === 1
? {
background:
"linear-gradient(94deg, #369BFD 4.8%, #36FDFD 77.04%, #36FDB5 143.99%)",
backgroundClip: "text",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
}
: undefined
}
>
Select your AI client
</button>
{selectedClient && (
<Select
onValueChange={(value) => {
setSelectedClient(value as keyof typeof clients)
setActiveStep(2)
}}
value={selectedClient || undefined}
>
<SelectTrigger
className="max-w-md rounded-full border-[#242A33] text-white hover:border-gray-600 bg-transparent!"
style={{
background:
"linear-gradient(0deg, #0A0E14 0%, #080B0F 100%)",
}}
>
{selectedClient ? (
<div className="flex items-center gap-2">
<Image
alt={clients[selectedClient]}
height={20}
width={20}
unoptimized
src={
selectedClient === "mcp-url"
? "/mcp-icon.svg"
: `/mcp-supported-tools/${selectedClient === "claude-code" ? "claude" : selectedClient}.png`
}
/>
<span>{clients[selectedClient]}</span>
</div>
) : (
<SelectValue placeholder="Select a client" />
)}
</SelectTrigger>
<SelectContent className="bg-black border-none">
{Object.entries(clients)
.slice(0, 7)
.map(([key, clientName]) => (
<SelectItem
key={key}
value={key}
className="text-white hover:bg-[#080B0F]"
>
<div className="flex items-center gap-2">
<Image
alt={clientName}
height={20}
width={20}
unoptimized
src={
key === "mcp-url"
? "/mcp-icon.svg"
: `/mcp-supported-tools/${key === "claude-code" ? "claude" : key}.png`
}
/>
<span>{clientName}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<div
className={cn(
"flex flex-wrap gap-2 mb-4",
selectedClient ? "hidden" : "",
)}
>
{Object.entries(clients)
.slice(0, 7)
.map(([key, clientName]) => (
<button
key={key}
type="button"
onClick={() => {
setSelectedClient(key as keyof typeof clients)
setActiveStep(2)
}}
className={`mcp-client-button-group py-[6px] pl-2 pr-3 rounded-full border transition-colors cursor-pointer duration-200 ${
selectedClient === key
? "border-blue-500 bg-blue-500/10"
: "border-[#242A33] bg-[#080B0F] hover:border-[#3273FC4D] hover:bg-[#08142D]"
}`}
>
<div className="flex items-center space-x-1">
<div className="w-5 h-5 flex items-center justify-center">
<Image
alt={clientName}
unoptimized
className="rounded object-contain"
height={20}
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = "none"
const parent = target.parentElement
if (
parent &&
!parent.querySelector(".fallback-text")
) {
const fallback = document.createElement("span")
fallback.className =
"fallback-text text-xs font-bold text-white"
fallback.textContent = clientName
.substring(0, 2)
.toUpperCase()
parent.appendChild(fallback)
}
}}
src={
key === "mcp-url"
? "/mcp-icon.svg"
: `/mcp-supported-tools/${key === "claude-code" ? "claude" : key}.png`
}
width={20}
/>
</div>
<span className="mcp-client-gradient-text text-sm font-medium text-white">
{clientName}
</span>
</div>
</button>
))}
</div>
{!selectedClient && (
<p className={cn("text-[#8B8B8B] text-[14px]", dmSansClassName())}>
*You can connect to all of these, setup is different for each one
</p>
)}
</div>
</div>
<div className="flex items-start space-x-4">
<div
className={cn(
"rounded-full w-8 h-8 flex items-center justify-center text-sm font-medium shrink-0 z-20 text-white",
activeStep === 2
? "border border-[#15233C] bg-[#08142D]"
: "bg-[#161F2B]",
)}
>
<span
className="text-lg"
style={
activeStep === 2
? {
background:
"linear-gradient(94deg, #369BFD 4.8%, #36FDFD 77.04%, #36FDB5 143.99%)",
backgroundClip: "text",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
}
: undefined
}
>
2
</span>
</div>
<div className="flex-1 mb-4">
<h3
className="text-white text-lg font-medium mb-4"
style={
activeStep === 2
? {
background:
"linear-gradient(94deg, #369BFD 4.8%, #36FDFD 77.04%, #36FDB5 143.99%)",
backgroundClip: "text",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
}
: undefined
}
>
Copy the installation command
</h3>
{selectedClient && (
<div className="space-y-3">
{selectedClient === "mcp-url" ? (
<div className="space-y-4">
<div className="flex justify-end">
<div className="flex bg-[#0D121A] rounded-full p-1 border border-gray-600">
<button
className={`px-3 py-1.5 text-xs font-medium rounded-full transition-all ${
mcpUrlTab === "oneClick"
? "bg-[#080B0F] text-white border border-gray-600"
: "text-gray-400 hover:text-white"
}`}
onClick={() => setMcpUrlTab("oneClick")}
type="button"
>
Quick Setup
</button>
<button
className={`px-3 py-1.5 text-xs font-medium rounded-full transition-all ${
mcpUrlTab === "manual"
? "bg-[#080B0F] text-white border border-gray-600"
: "text-gray-400 hover:text-white"
}`}
onClick={() => setMcpUrlTab("manual")}
type="button"
>
Manual Config
</button>
</div>
</div>
{mcpUrlTab === "oneClick" ? (
<div className="space-y-2">
<p className="text-sm text-gray-400">
Use this URL to quickly configure supermemory in your AI
assistant
</p>
<div className="relative">
<input
className="font-mono text-xs w-full pr-10 p-2 bg-black border border-gray-600 rounded text-green-400"
readOnly
value="https://api.supermemory.ai/mcp"
/>
<button
type="button"
className="absolute top-1 right-1 cursor-pointer p-1"
onClick={() => {
navigator.clipboard.writeText(
"https://api.supermemory.ai/mcp",
)
analytics.mcpInstallCmdCopied()
toast.success("Copied to clipboard!")
setActiveStep(3)
}}
>
<CopyIcon className="size-4 text-gray-400 hover:text-white" />
</button>
</div>
</div>
) : (
<div className="space-y-3">
<p className="text-sm text-gray-400">
Add this configuration to your MCP settings file with
authentication
</p>
<div className="relative">
<pre className="bg-black border border-gray-600 rounded-lg p-4 pr-12 text-xs overflow-x-auto max-w-full">
<code className="font-mono block whitespace-pre-wrap break-all text-green-400">
{`{
"supermemory-mcp": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://api.supermemory.ai/mcp"],
"env": {},
"headers": {
"Authorization": "Bearer your-api-key-here"
}
}
}`}
</code>
</pre>
<button
type="button"
className="absolute top-2 right-2 cursor-pointer h-8 w-8 p-0 bg-[#0D121A] hover:bg-[#1a1a1a] rounded"
onClick={() => {
const config = `{
"supermemory-mcp": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://api.supermemory.ai/mcp"],
"env": {},
"headers": {
"Authorization": "Bearer your-api-key-here"
}
}
}`
navigator.clipboard.writeText(config)
analytics.mcpInstallCmdCopied()
toast.success("Copied to clipboard!")
setIsCopied(true)
setActiveStep(3)
setTimeout(() => setIsCopied(false), 2000)
}}
>
{isCopied ? (
<span className="text-green-600">✓</span>
) : (
<CopyIcon className="size-3.5" />
)}
</button>
</div>
<p className="text-xs text-gray-400">
The API key is included as a Bearer token in the
Authorization header
</p>
</div>
)}
</div>
) : (
<div className="space-y-3">
<div className="relative">
<input
className={cn(
"text-xs w-full pr-24 py-4 bg-[#0D121A] rounded-xl text-white pl-3",
dmMonoClassName(),
)}
style={{
border: "1px solid rgba(61, 67, 77, 0.10)",
textOverflow: "ellipsis",
overflow: "hidden",
whiteSpace: "nowrap",
}}
readOnly
value={generateInstallCommand()}
/>
<button
type="button"
className={cn(
"absolute top-[5px] right-1 cursor-pointer p-1 flex items-center rounded-[10px] px-3 py-2 gap-2",
dmSansClassName(),
)}
style={{
background:
"linear-gradient(180deg, #267BF1 40.23%, #15468B 100%), linear-gradient(180deg, #0D121A -26.14%, #000 100%)",
border: "1px solid #000",
}}
onClick={copyToClipboard}
>
{isCopied ? (
<>
<Check className="size-4 text-white" />
<span className="text-white">Copied</span>
</>
) : (
<>
<CopyIcon className="size-[20px] text-white stroke-[2px]" />
<span className="text-white">Copy</span>
</>
)}
</button>
</div>
</div>
)}
</div>
)}
</div>
</div>
<div className="flex items-start space-x-4">
<div
className={cn(
"rounded-full w-8 h-8 flex items-center justify-center text-sm font-medium shrink-0 z-20 text-white",
activeStep === 3
? "border border-[#15233C] bg-[#08142D]"
: "bg-[#161F2B] ",
)}
>
<span
className="text-lg"
style={
activeStep === 3
? {
background:
"linear-gradient(94deg, #369BFD 4.8%, #36FDFD 77.04%, #36FDB5 143.99%)",
backgroundClip: "text",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
}
: undefined
}
>
3
</span>
</div>
<div className="flex-1 space-y-4">
<h3
className="text-white text-lg font-medium"
style={
activeStep === 3
? {
background:
"linear-gradient(94deg, #369BFD 4.8%, #36FDFD 77.04%, #36FDB5 143.99%)",
backgroundClip: "text",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
}
: undefined
}
>
Run command in your terminal
</h3>
{activeStep === 3 && (
<p
className={cn(
"font-mono text-xs w-full pr-10 p-4 px-2 bg-[#0D121A] rounded-xl text-white pl-3 flex items-center gap-2",
dmMonoClassName(),
)}
style={{
border: "1px solid rgba(61, 67, 77, 0.10)",
}}
>
<SyncLogoIcon className="size-4" />
Waiting for installation
</p>
)}
</div>
</div>
</div>
)
}
interface MCPDetailViewProps {
onBack: () => void
}
export function MCPDetailView({ onBack }: MCPDetailViewProps) {
return (
<div className="flex flex-col h-full p-8">
<div className="mb-6">
<Button
variant="link"
className="text-white hover:text-gray-300 p-0 hover:no-underline cursor-pointer"
onClick={onBack}
>
← Back
</Button>
</div>
<div className="flex-1 flex flex-col items-start justify-start">
<h1 className="text-white text-xl font-medium mb-4 text-start">
Connect your AI to supermemory MCP
</h1>
<div className="mb-12 space-x-4 flex max-w-2xl">
<div
className={cn(
"flex items-start space-x-3 w-[200px]",
dmSansClassName(),
)}
>
<CircleCheckIcon className="size-4 text-green-500 shrink-0 mt-0.5" />
<p className="text-[#8B8B8B] text-sm">
MCP connects your AI apps to create and use memories directly
</p>
</div>
<div
className={cn(
"flex items-start space-x-3 w-[200px]",
dmSansClassName(),
)}
>
<CircleCheckIcon className="size-4 text-green-500 shrink-0 mt-0.5" />
<p className="text-[#8B8B8B] text-sm">
Auto-fetch the right context from anything you've saved
</p>
</div>
<div
className={cn(
"flex items-start space-x-3 w-[200px]",
dmSansClassName(),
)}
>
<CircleCheckIcon className="size-4 text-green-500 shrink-0 mt-0.5" />
<p className="text-[#8B8B8B] text-sm">
One-time setup, <br /> seamless integration across your workflow
</p>
</div>
</div>
<MCPSteps variant="full" />
</div>
</div>
)
}

View file

@ -0,0 +1,423 @@
"use client"
import { useAuth } from "@lib/auth-context"
import { $fetch } from "@repo/lib/api"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import { useInfiniteQuery } from "@tanstack/react-query"
import { useCallback, memo, useMemo, useState, useRef } from "react"
import type { z } from "zod"
import {
Masonry,
useInfiniteLoader,
} from "masonic"
import { dmSansClassName } from "@/utils/fonts"
import { SuperLoader } from "@/components/superloader"
import { cn } from "@lib/utils"
import { Button } from "@ui/components/button"
import { useProject } from "@/stores"
import { useIsMobile } from "@hooks/use-mobile"
import type { Tweet } from "react-tweet/api"
import { TweetPreview } from "./document-cards/tweet-preview"
import { WebsitePreview } from "./document-cards/website-preview"
import { GoogleDocsPreview } from "./document-cards/google-docs-preview"
import { FilePreview } from "./document-cards/file-preview"
import { NotePreview } from "./document-cards/note-preview"
import { YoutubePreview } from "./document-cards/youtube-preview"
import { getAbsoluteUrl, isYouTubeUrl, useYouTubeChannelName } from "./utils"
import { SyncLogoIcon } from "@ui/assets/icons"
import { McpPreview } from "./document-cards/mcp-preview"
import { DocumentModal } from "./document-modal"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
const IS_DEV = process.env.NODE_ENV === "development"
const PAGE_SIZE = IS_DEV ? 100 : 100
const MAX_TOTAL = 1000
export function MemoriesGrid({ isChatOpen }: { isChatOpen: boolean }) {
const { user } = useAuth()
const { selectedProject } = useProject()
const isMobile = useIsMobile()
const [selectedDocument, setSelectedDocument] =
useState<DocumentWithMemories | null>(null)
const [isModalOpen, setIsModalOpen] = useState(false)
const {
data,
error,
isPending,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
} = useInfiniteQuery<DocumentsResponse, Error>({
queryKey: ["documents-with-memories", selectedProject],
initialPageParam: 1,
queryFn: async ({ pageParam }) => {
const response = await $fetch("@post/documents/documents", {
body: {
page: pageParam as number,
limit: (pageParam as number) === 1 ? (IS_DEV ? 500 : 500) : PAGE_SIZE,
sort: "createdAt",
order: "desc",
containerTags: selectedProject ? [selectedProject] : undefined,
},
disableValidation: true,
})
if (response.error) {
throw new Error(response.error?.message || "Failed to fetch documents")
}
return response.data
},
getNextPageParam: (lastPage, allPages) => {
const loaded = allPages.reduce(
(acc, p) => acc + (p.documents?.length ?? 0),
0,
)
if (loaded >= MAX_TOTAL) return undefined
const { currentPage, totalPages } = lastPage.pagination
if (currentPage < totalPages) {
return currentPage + 1
}
return undefined
},
staleTime: 5 * 60 * 1000,
enabled: !!user,
})
const documents = useMemo(() => {
return (
data?.pages.flatMap((p: DocumentsResponse) => p.documents ?? []) ?? []
)
}, [data])
const loadMoreDocuments = useCallback(async (): Promise<void> => {
if (hasNextPage && !isFetchingNextPage) {
await fetchNextPage()
return
}
return
}, [hasNextPage, isFetchingNextPage, fetchNextPage])
const maybeLoadMore = useInfiniteLoader(
async (_startIndex, _stopIndex, _currentItems) => {
if (hasNextPage && !isFetchingNextPage) {
await loadMoreDocuments()
}
},
{
isItemLoaded: (index, items) => !!items[index],
minimumBatchSize: 10,
threshold: 5,
},
)
const handleCardClick = useCallback((document: DocumentWithMemories) => {
setSelectedDocument(document)
setIsModalOpen(true)
}, [])
const renderDocumentCard = useCallback(
({
index,
data,
width,
}: {
index: number
data: DocumentWithMemories
width: number
}) => (
<DocumentCard
index={index}
data={data}
width={width}
onClick={handleCardClick}
/>
),
[handleCardClick],
)
if (!user) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center text-muted-foreground">
<p>Please log in to view your memories</p>
</div>
</div>
)
}
return (
<div
className="h-full"
>
<Button
className={cn(
dmSansClassName(),
"rounded-full border border-[#161F2C] bg-[#0D121A] px-4 py-2 data-[state=active]:bg-[#00173C] data-[state=active]:border-[#2261CA33] mb-4",
)}
data-state="active"
>
All
</Button>
{error ? (
<div className="h-full flex items-center justify-center p-4">
<div className="text-center text-muted-foreground">
Error loading documents: {error.message}
</div>
</div>
) : isPending ? (
<div className="h-full flex items-center justify-center p-4">
<SuperLoader />
</div>
) : documents.length === 0 && !isPending ? (
<div className="h-full flex items-center justify-center p-4">
<div className="text-center text-muted-foreground">
No memories found
</div>
</div>
) : (
<div className="h-full overflow-auto scrollbar-thin">
<Masonry
key={`masonry-${documents.length}-${documents.map((d) => d.id).join(",")}-${isChatOpen}`}
items={documents}
render={renderDocumentCard}
columnGutter={0}
rowGutter={0}
columnWidth={216}
maxColumnCount={isMobile ? 1 : undefined}
itemHeightEstimate={200}
overscanBy={3}
onRender={maybeLoadMore}
/>
{isFetchingNextPage && (
<div className="py-8 flex items-center justify-center">
<SuperLoader />
</div>
)}
</div>
)}
<DocumentModal
document={selectedDocument}
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
/>
</div>
)
}
function DocumentUrlDisplay({ url }: { url: string }) {
const isYouTube = isYouTubeUrl(url)
const { data: channelName, isLoading } = useYouTubeChannelName(
isYouTube ? url : null,
)
if (isYouTube) {
return (
<p
className={cn(
dmSansClassName(),
"text-[10px] text-[#737373] line-clamp-1",
)}
>
{isLoading ? "YouTube" : channelName || "YouTube"}
</p>
)
}
return (
<p
className={cn(
dmSansClassName(),
"text-[10px] text-[#737373] line-clamp-1",
)}
>
{getAbsoluteUrl(url)}
</p>
)
}
const DocumentCard = memo(
({
index: _index,
data: document,
width,
onClick,
}: {
index: number
data: DocumentWithMemories
width: number
onClick: (document: DocumentWithMemories) => void
}) => {
const [rotation, setRotation] = useState({ rotateX: 0, rotateY: 0 })
const cardRef = useRef<HTMLButtonElement>(null)
const handleMouseMove = (e: React.MouseEvent<HTMLButtonElement>) => {
if (!cardRef.current) return
const rect = cardRef.current.getBoundingClientRect()
const centerX = rect.left + rect.width / 2
const centerY = rect.top + rect.height / 2
const mouseX = e.clientX - centerX
const mouseY = e.clientY - centerY
// Calculate rotation angles (max 15 degrees)
const rotateY = (mouseX / (rect.width / 2)) * 15
const rotateX = -(mouseY / (rect.height / 2)) * 15
setRotation({ rotateX, rotateY })
}
const handleMouseLeave = () => {
setRotation({ rotateX: 0, rotateY: 0 })
}
return (
<div className="p-2" style={{ width }}>
<button
ref={cardRef}
type="button"
className={cn(
"rounded-[22px] bg-[#1B1F24] px-1 space-y-2 pt-1 cursor-pointer w-full",
"border-none text-left transition-transform duration-200 ease-out",
document.type === "image" ||
document.metadata?.mimeType?.toString().startsWith("image/")
? "pb-1"
: "",
)}
onClick={() => onClick(document)}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
style={{
boxShadow:
"0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
transform: `perspective(1000px) rotateX(${rotation.rotateX}deg) rotateY(${rotation.rotateY}deg)`,
transformStyle: "preserve-3d",
}}
>
<ContentPreview document={document} />
{!(
document.type === "image" ||
document.metadata?.mimeType?.toString().startsWith("image/")
) && (
<div className="pb-[10px] space-y-1">
{document.url &&
!document.url.includes("x.com") &&
!document.url.includes("twitter.com") &&
!document.url.includes("files.supermemory.ai") && (
<div className="px-3">
<p
className={cn(
dmSansClassName(),
"text-[12px] text-[#E5E5E5] line-clamp-1 font-semibold",
)}
>
{document.title}
</p>
<DocumentUrlDisplay url={document.url} />
</div>
)}
<div
className={cn(
"flex items-center px-3",
document.memoryEntries.length > 0
? "justify-between"
: "justify-end",
)}
>
{document.memoryEntries.length > 0 && (
<p
className={cn(
dmSansClassName(),
"text-[10px] text-[#369BFD] line-clamp-1 font-semibold flex items-center gap-1",
)}
style={{
background:
"linear-gradient(94deg, #369BFD 4.8%, #36FDFD 77.04%, #36FDB5 143.99%)",
backgroundClip: "text",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
}}
>
<SyncLogoIcon className="w-[12.33px] h-[10px]" />
{document.memoryEntries.length}{" "}
{document.memoryEntries.length === 1
? "memory"
: "memories"}
</p>
)}
<p
className={cn(
dmSansClassName(),
"text-[10px] text-[#737373] line-clamp-1",
)}
>
{new Date(document.createdAt).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
</p>
</div>
</div>
)}
</button>
</div>
)
},
)
DocumentCard.displayName = "DocumentCard"
function ContentPreview({ document }: { document: DocumentWithMemories }) {
if (
document.url?.includes("https://docs.googleapis.com/v1/documents") ||
document.url?.includes("docs.google.com/document") ||
document.type === "google_doc"
) {
return <GoogleDocsPreview document={document} />
}
if (
document.url?.includes("x.com/") &&
document.metadata?.sm_internal_twitter_metadata
) {
return (
<TweetPreview
data={
document.metadata?.sm_internal_twitter_metadata as unknown as Tweet
}
/>
)
}
if (document.source === "mcp") {
return <McpPreview document={document} />
}
if (isYouTubeUrl(document.url)) {
return <YoutubePreview document={document} />
}
if (
document.type === "pdf" ||
document.type === "image" ||
document.type === "video" ||
document.metadata?.mimeType
) {
return <FilePreview document={document} />
}
if (document.url?.includes("https://")) {
return <WebsitePreview document={document} />
}
// Default to Note
return <NotePreview document={document} />
}

View file

@ -0,0 +1,694 @@
"use client"
import { dmSans125ClassName } from "@/utils/fonts"
import { cn } from "@lib/utils"
import { useAuth } from "@lib/auth-context"
import { fetchMemoriesFeature, fetchSubscriptionStatus } from "@lib/queries"
import { Avatar, AvatarFallback, AvatarImage } from "@ui/components/avatar"
import {
Dialog,
DialogContent,
DialogTrigger,
DialogClose,
} from "@ui/components/dialog"
import { useCustomer } from "autumn-js/react"
import { Check, X, Trash2, LoaderIcon, Settings } from "lucide-react"
import { useState } from "react"
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<p
className={cn(
dmSans125ClassName(),
"font-semibold text-[20px] tracking-[-0.2px] text-[#FAFAFA] px-2",
)}
>
{children}
</p>
)
}
function SettingsCard({ children }: { children: React.ReactNode }) {
return (
<div
className={cn(
"relative bg-[#14161A] rounded-[14px] p-6 w-full overflow-hidden",
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
)}
>
{children}
</div>
)
}
function PlanFeatureRow({
icon,
text,
variant = "muted",
}: {
icon: "check" | "x"
text: string
variant?: "muted" | "highlight"
}) {
return (
<div className="flex items-center gap-2">
{icon === "check" ? (
<Check
className={cn(
"size-4 shrink-0",
variant === "highlight" ? "text-[#4BA0FA]" : "text-[#737373]",
)}
/>
) : (
<X className="size-4 shrink-0 text-[#737373]" />
)}
<span
className={cn(
dmSans125ClassName(),
"text-[14px] tracking-[-0.14px]",
variant === "highlight" ? "text-white" : "text-[#737373]",
)}
>
{text}
</span>
</div>
)
}
export default function Account() {
const { user, org } = useAuth()
const autumn = useCustomer()
const [isUpgrading, setIsUpgrading] = useState(false)
const [deleteConfirmText, setDeleteConfirmText] = useState("")
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
// Billing data
const {
data: status = {
consumer_pro: { allowed: false, status: null },
},
isLoading: isCheckingStatus,
} = fetchSubscriptionStatus(autumn, !autumn.isLoading)
const proStatus = status.consumer_pro
const hasProProduct = proStatus?.status !== null
const { data: memoriesCheck } = fetchMemoriesFeature(
autumn,
!autumn.isLoading && !isCheckingStatus,
)
const memoriesUsed = memoriesCheck?.usage ?? 0
const memoriesLimit = memoriesCheck?.included_usage ?? 200
// Calculate progress percentage
const usagePercent = Math.min((memoriesUsed / memoriesLimit) * 100, 100)
// Handlers
const handleUpgrade = async () => {
setIsUpgrading(true)
try {
await autumn.attach({
productId: "consumer_pro",
successUrl: "https://app.supermemory.ai/new/settings#account",
})
window.location.reload()
} catch (error) {
console.error(error)
setIsUpgrading(false)
}
}
const handleDeleteAccount = () => {
if (deleteConfirmText !== "DELETE") return
// TODO: Implement account deletion API call
console.log("Delete account requested")
setIsDeleteDialogOpen(false)
setDeleteConfirmText("")
}
const isDeleteEnabled = deleteConfirmText === "DELETE"
// Format member since date
const memberSince = user?.createdAt
? new Date(user.createdAt).toLocaleDateString("en-US", {
month: "short",
year: "numeric",
})
: "—"
return (
<div className="flex flex-col gap-8 pt-4 w-full ">
<section id="profile-details" className="flex flex-col gap-4">
<SectionTitle>Profile Details</SectionTitle>
<SettingsCard>
<div className="flex flex-col gap-6">
{/* Avatar + Name/Email */}
<div className="flex items-center gap-4">
<div className="relative size-16 rounded-full bg-linear-to-b from-[#0D121A] to-black overflow-hidden shrink-0">
<Avatar className="size-full">
<AvatarImage
src={user?.image ?? ""}
alt={user?.name ?? "User"}
className="object-cover"
/>
<AvatarFallback className="bg-transparent text-white text-xl">
{user?.name?.charAt(0) ?? "U"}
</AvatarFallback>
</Avatar>
</div>
<div className="flex flex-col gap-1.5">
<p
className={cn(
dmSans125ClassName(),
"font-semibold text-[20px] tracking-[-0.2px] text-[#FAFAFA]",
)}
>
{user?.name ?? "—"}
</p>
<p
className={cn(
dmSans125ClassName(),
"font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
)}
>
{user?.email ?? "—"}
</p>
</div>
</div>
{/* Organization + Member since */}
<div className="flex gap-4">
<div className="flex-1 flex flex-col gap-2">
<p
className={cn(
dmSans125ClassName(),
"font-medium text-[16px] tracking-[-0.16px] text-[#737373]",
)}
>
Organization
</p>
<p
className={cn(
dmSans125ClassName(),
"font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
)}
>
{org?.name ?? "Personal"}
</p>
</div>
<div className="flex-1 flex flex-col gap-2">
<p
className={cn(
dmSans125ClassName(),
"font-medium text-[16px] tracking-[-0.16px] text-[#737373]",
)}
>
Member since
</p>
<p
className={cn(
dmSans125ClassName(),
"font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
)}
>
{memberSince}
</p>
</div>
</div>
</div>
</SettingsCard>
</section>
<section id="billing-subscription" className="flex flex-col gap-4">
<SectionTitle>Billing &amp; Subscription</SectionTitle>
<SettingsCard>
<div className="flex flex-col gap-6">
{hasProProduct ? (
<>
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-4">
<p
className={cn(
dmSans125ClassName(),
"font-semibold text-[20px] tracking-[-0.2px] text-[#FAFAFA]",
)}
>
Pro plan
</p>
<span className="bg-[#4BA0FA] text-[#00171A] text-[12px] font-bold tracking-[0.36px] px-1 py-[3px] rounded-[3px] h-[18px] flex items-center justify-center">
ACTIVE
</span>
</div>
<p
className={cn(
dmSans125ClassName(),
"font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
)}
>
Expanded memory with connections and more
</p>
</div>
<div id="progress-bar" className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<p
className={cn(
dmSans125ClassName(),
"font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
)}
>
Unlimited Memories
</p>
<div className="flex items-center">
<span
className={cn(
dmSans125ClassName(),
"font-medium text-[16px] tracking-[-0.16px] text-[#4BA0FA]",
)}
>
{memoriesUsed}/
</span>
<span className="text-[#4BA0FA] text-[20px] leading-none ml-0.5">
∞
</span>
</div>
</div>
<div
id="progress-bar-fill"
className="h-3 w-full rounded-[40px] bg-[#2E353D] blur-[1px] p-px overflow-hidden"
>
<div
className="h-full w-full rounded-[40px]"
style={{
background:
"linear-gradient(to right, #4BA0FA 80.517%, #002757 100%)",
}}
/>
</div>
</div>
<button
type="button"
onClick={() => {
autumn.openBillingPortal?.()
}}
className={cn(
"relative w-full h-11 rounded-full flex items-center justify-center gap-2",
"bg-[#0D121A] border border-[rgba(115,115,115,0.2)]",
"text-[#FAFAFA] font-medium text-[14px] tracking-[-0.14px]",
"cursor-pointer transition-opacity hover:opacity-90",
dmSans125ClassName(),
)}
>
<Settings className="size-4" />
Manage billing
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]" />
</button>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Free plan card */}
<div className="flex flex-col gap-4 p-4 rounded-[10px] border border-white/10 overflow-hidden">
<p
className={cn(
dmSans125ClassName(),
"font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
)}
>
Free plan
</p>
<div className="flex flex-col gap-2">
<PlanFeatureRow icon="x" text="Limited 200 memories" />
<PlanFeatureRow icon="x" text="No connections" />
<PlanFeatureRow icon="check" text="Basic search" />
<PlanFeatureRow icon="check" text="Basic support" />
</div>
</div>
{/* Pro plan card - highlighted */}
<div
className={cn(
"flex flex-col gap-4 p-4 rounded-[10px]",
"bg-[#1B1F24]",
"shadow-[0px_2.842px_14.211px_rgba(0,0,0,0.25)]",
"relative overflow-hidden",
)}
>
{/* Header with ACTIVE badge */}
<div className="flex items-center justify-between">
<p
className={cn(
dmSans125ClassName(),
"font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
)}
>
Pro plan
</p>
<span className="bg-[#4BA0FA] text-[#00171A] text-[12px] font-bold tracking-[0.36px] px-1 py-[3px] rounded-[3px] h-[18px] flex items-center justify-center">
ACTIVE
</span>
</div>
<div className="flex flex-col gap-2">
<PlanFeatureRow
icon="check"
text="Unlimited memories"
variant="highlight"
/>
<PlanFeatureRow
icon="check"
text="10 connections"
variant="highlight"
/>
<PlanFeatureRow
icon="check"
text="Advanced search"
variant="highlight"
/>
<PlanFeatureRow
icon="check"
text="Priority support"
variant="highlight"
/>
</div>
{/* Inset highlight */}
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_0.711px_0.711px_0.711px_rgba(255,255,255,0.1)]" />
</div>
</div>
</>
) : (
<>
<div className="flex flex-col gap-1.5">
<p
className={cn(
dmSans125ClassName(),
"font-semibold text-[20px] tracking-[-0.2px] text-[#FAFAFA]",
)}
>
Free Plan
</p>
<p
className={cn(
dmSans125ClassName(),
"font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
)}
>
You are on basic plan
</p>
</div>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<p
className={cn(
dmSans125ClassName(),
"font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
)}
>
Memories
</p>
<p
className={cn(
dmSans125ClassName(),
"font-medium text-[16px] tracking-[-0.16px] text-[#737373]",
)}
>
{memoriesUsed}/{memoriesLimit}
</p>
</div>
{/* Progress bar */}
<div className="h-3 w-full rounded-[40px] bg-[#2E353D] p-px">
<div
className="h-full rounded-[40px] bg-[#0054AD] transition-all"
style={{ width: `${usagePercent}%` }}
/>
</div>
</div>
<button
type="button"
onClick={handleUpgrade}
disabled={isUpgrading || isCheckingStatus || autumn.isLoading}
className={cn(
"relative w-full h-11 rounded-[10px] flex items-center justify-center",
"text-[#FAFAFA] font-medium text-[14px] tracking-[-0.14px]",
"shadow-[0px_2px_10px_rgba(5,1,0,0.2)]",
"disabled:opacity-60 disabled:cursor-not-allowed",
"cursor-pointer transition-opacity hover:opacity-90",
dmSans125ClassName(),
)}
style={{
background:
"linear-gradient(182.37deg, #0ff0d2 -91.53%, #5bd3fb -67.8%, #1e0ff0 95.17%)",
boxShadow:
"1px 1px 2px 0px #1A88FF inset, 0 2px 10px 0 rgba(5, 1, 0, 0.20)",
}}
>
{isUpgrading || isCheckingStatus || autumn.isLoading ? (
<>
<LoaderIcon className="size-4 animate-spin mr-2" />
Upgrading...
</>
) : (
"Upgrade to Pro - $9/month"
)}
{/* Inset blue stroke */}
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_1px_1px_2px_1px_#1A88FF]" />
</button>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Free plan card */}
<div className="flex flex-col gap-4 p-4 rounded-[10px] border border-white/10">
<p
className={cn(
dmSans125ClassName(),
"font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
)}
>
Free plan
</p>
<div className="flex flex-col gap-2">
<PlanFeatureRow icon="x" text="Limited 200 memories" />
<PlanFeatureRow icon="x" text="No connections" />
<PlanFeatureRow icon="check" text="Basic search" />
<PlanFeatureRow icon="check" text="Basic support" />
</div>
</div>
{/* Pro plan card */}
<div
className={cn(
"flex flex-col gap-4 p-4 rounded-[10px]",
"bg-[#1B1F24] border border-white/10",
"shadow-[0px_2.842px_14.211px_rgba(0,0,0,0.25)]",
"relative overflow-hidden",
)}
>
{/* Header with badge */}
<div className="flex items-center justify-between">
<p
className={cn(
dmSans125ClassName(),
"font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
)}
>
Pro plan
</p>
<span className="bg-[#4BA0FA] text-[#00171A] text-[12px] font-bold tracking-[0.36px] px-1 py-0.5 rounded-[3px]">
RECOMMENDED
</span>
</div>
<div className="flex flex-col gap-2">
<PlanFeatureRow
icon="check"
text="Unlimited memories"
variant="highlight"
/>
<PlanFeatureRow
icon="check"
text="10 connections"
variant="highlight"
/>
<PlanFeatureRow
icon="check"
text="Advanced search"
variant="highlight"
/>
<PlanFeatureRow
icon="check"
text="Priority support"
variant="highlight"
/>
</div>
{/* Inset highlight */}
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_0.711px_0.711px_0.711px_rgba(255,255,255,0.1)]" />
</div>
</div>
</>
)}
</div>
</SettingsCard>
</section>
<section id="delete-account" className="flex flex-col gap-4">
<SectionTitle>Delete Account</SectionTitle>
<SettingsCard>
<div className="flex items-center justify-between gap-4">
<p
className={cn(
dmSans125ClassName(),
"font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA] max-w-[350px]",
)}
>
Permanently delete all your data and cancel any active
subscriptions
</p>
<Dialog
open={isDeleteDialogOpen}
onOpenChange={(open) => {
setIsDeleteDialogOpen(open)
if (!open) setDeleteConfirmText("")
}}
>
<DialogTrigger asChild>
<button
type="button"
className={cn(
"relative flex items-center gap-1.5 px-4 py-2 rounded-full",
"bg-[#290F0A] text-[#C73B1B]",
"font-normal text-[14px] tracking-[-0.14px]",
"cursor-pointer transition-opacity hover:opacity-90",
"shrink-0",
dmSans125ClassName(),
)}
>
<Trash2 className="size-[18px]" />
<span>Delete</span>
{/* Inset shadow */}
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.4)]" />
</button>
</DialogTrigger>
<DialogContent
showCloseButton={false}
className={cn(
"bg-[#1B1F24] rounded-[22px] p-4",
"shadow-[0px_2.842px_14.211px_rgba(0,0,0,0.25)]",
"min-w-xl",
)}
>
<div className="flex flex-col gap-4">
{/* Header */}
<div className="flex flex-col gap-6">
<div className="flex items-start gap-4">
<div className="flex-1 flex flex-col gap-1 pl-1">
<p
className={cn(
dmSans125ClassName(),
"font-semibold text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
)}
>
Delete account?
</p>
<p
className={cn(
dmSans125ClassName(),
"font-medium text-[16px] tracking-[-0.16px] text-[#737373] leading-[1.35]",
)}
>
This will permanently delete your memories,
conversations, settings and cancel any active
subscriptions.
</p>
</div>
<DialogClose asChild>
<button
type="button"
className={cn(
"relative size-7 rounded-full bg-[#0D121A] border border-[#73737333]",
"flex items-center justify-center shrink-0",
"cursor-pointer transition-opacity hover:opacity-80",
)}
>
<X className="size-4 text-[#737373]" />
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_1.313px_1.313px_3.938px_rgba(0,0,0,0.7)]" />
</button>
</DialogClose>
</div>
{/* Confirmation input */}
<div className="flex flex-col gap-4">
<p
className={cn(
dmSans125ClassName(),
"font-semibold text-[16px] tracking-[-0.16px] text-[#FAFAFA] pl-2",
)}
>
Type <span className="text-[#C73B1B]">DELETE</span> to
confirm:
</p>
<div
className={cn(
"relative bg-[#14161A] border border-[#52596614] rounded-[12px]",
"shadow-[0px_1px_2px_rgba(0,43,87,0.1)]",
)}
>
<input
type="text"
value={deleteConfirmText}
onChange={(e) => setDeleteConfirmText(e.target.value)}
placeholder="DELETE"
className={cn(
"w-full px-4 py-3 bg-transparent",
"text-[#FAFAFA] placeholder:text-[#737373]",
"text-[14px] tracking-[-0.14px]",
"outline-none",
dmSans125ClassName(),
)}
/>
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_0px_0px_0px_1px_rgba(43,49,67,0.08),inset_0px_1px_1px_rgba(0,0,0,0.08),inset_0px_2px_4px_rgba(0,0,0,0.02)]" />
</div>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-5">
<DialogClose asChild>
<button
type="button"
className={cn(
dmSans125ClassName(),
"font-medium text-[14px] tracking-[-0.14px] text-[#737373]",
"cursor-pointer transition-opacity hover:opacity-80",
)}
>
Cancel
</button>
</DialogClose>
<button
type="button"
onClick={handleDeleteAccount}
disabled={!isDeleteEnabled}
className={cn(
"relative flex items-center gap-1.5 pl-4 pr-[18px] py-2 rounded-full",
"bg-[#290F0A] text-[#C73B1B]",
"font-normal text-[14px] tracking-[-0.14px]",
"cursor-pointer transition-opacity",
"disabled:opacity-40 disabled:cursor-not-allowed",
isDeleteEnabled && "hover:opacity-90",
dmSans125ClassName(),
)}
>
<Trash2 className="size-[18px]" />
<span>Delete</span>
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.4)]" />
</button>
</div>
</div>
{/* Modal inset highlight */}
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_0.711px_0.711px_0.711px_rgba(255,255,255,0.1)]" />
</DialogContent>
</Dialog>
</div>
</SettingsCard>
</section>
</div>
)
}

View file

@ -0,0 +1,568 @@
"use client"
import { dmSans125ClassName } from "@/utils/fonts"
import { cn } from "@lib/utils"
import { $fetch } from "@lib/api"
import { fetchSubscriptionStatus } from "@lib/queries"
import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons"
import { useCustomer } from "autumn-js/react"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { Check, Plus, Trash2, Zap } from "lucide-react"
import { useEffect, useState } from "react"
import { toast } from "sonner"
import type { ConnectionResponseSchema } from "@repo/validation/api"
import type { z } from "zod"
import { analytics } from "@/lib/analytics"
import { ConnectAIModal } from "@/components/connect-ai-modal"
import { AddDocumentModal } from "@/components/new/add-document"
import { DEFAULT_PROJECT_ID } from "@repo/lib/constants"
import type { Project } from "@repo/lib/types"
type Connection = z.infer<typeof ConnectionResponseSchema>
const CONNECTORS = {
"google-drive": {
title: "Google Drive",
description: "Connect your Google Docs, Sheets, and Slides",
icon: GoogleDrive,
documentLabel: "documents",
},
notion: {
title: "Notion",
description: "Import your Notion pages and databases",
icon: Notion,
documentLabel: "pages",
},
onedrive: {
title: "OneDrive",
description: "Access your Microsoft Office documents",
icon: OneDrive,
documentLabel: "documents",
},
} as const
type ConnectorProvider = keyof typeof CONNECTORS
function SectionTitle({
children,
badge,
}: {
children: React.ReactNode
badge?: React.ReactNode
}) {
return (
<div className="flex items-center justify-between px-2">
<p
className={cn(
dmSans125ClassName(),
"font-semibold text-[20px] tracking-[-0.2px] text-[#FAFAFA]",
)}
>
{children}
</p>
{badge}
</div>
)
}
function ProBadge() {
return (
<span className="bg-[#4BA0FA] text-[#00171A] text-[12px] font-bold tracking-[0.36px] px-1 py-0.5 rounded-[3px]">
PRO
</span>
)
}
function ConnectionsCard({
children,
className,
}: {
children: React.ReactNode
className?: string
}) {
return (
<div
className={cn(
"relative bg-[#14161A] rounded-[14px] p-6 w-full overflow-hidden",
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
className,
)}
>
{children}
</div>
)
}
function PillButton({
children,
onClick,
disabled,
className,
}: {
children: React.ReactNode
onClick?: () => void
disabled?: boolean
className?: string
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={cn(
"relative flex items-center justify-center gap-2",
"bg-[#0D121A]",
"rounded-full h-11 px-4 w-full",
"cursor-pointer transition-opacity hover:opacity-80",
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]",
"disabled:opacity-50 disabled:cursor-not-allowed",
dmSans125ClassName(),
className,
)}
>
{children}
</button>
)
}
function ConnectionStatusBadge({ connected }: { connected: boolean }) {
return (
<div className="flex items-center gap-2">
<div
className={cn(
"size-[7px] rounded-full",
connected ? "bg-[#00AC3F]" : "bg-[#737373]",
)}
/>
<span
className={cn(
dmSans125ClassName(),
"font-medium text-[16px] tracking-[-0.16px]",
connected ? "text-[#00AC3F]" : "text-[#737373]",
)}
>
{connected ? "Connected" : "Disconnected"}
</span>
</div>
)
}
function ConnectionRow({
connection,
onDelete,
isDeleting,
disabled,
projects,
}: {
connection: Connection
onDelete: () => void
isDeleting: boolean
disabled?: boolean
projects: Project[]
}) {
const config = CONNECTORS[connection.provider as ConnectorProvider]
if (!config) return null
const Icon = config.icon
// Check if connection is active: if expiresAt exists and is in the future, or if no expiresAt
const isConnected =
!connection.expiresAt || new Date(connection.expiresAt) > new Date()
// Format relative time
const formatRelativeTime = (date: string | null | undefined) => {
if (!date) return "Never"
const d = new Date(date)
const now = new Date()
const diffMs = now.getTime() - d.getTime()
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
const diffDays = Math.floor(diffHours / 24)
if (diffHours < 1) return "Just now"
if (diffHours < 24) return `${diffHours}h ago`
if (diffDays === 1) return "Yesterday"
if (diffDays < 7) return `${diffDays} days ago`
return d.toLocaleDateString()
}
const getProjectDisplayName = (containerTag: string): string => {
if (containerTag === DEFAULT_PROJECT_ID) return "Default Project"
const found = projects.find((p) => p.containerTag === containerTag)
if (found) return found.name
return containerTag.replace(/^sm_project_/, "") // if cached project is not found, remove the prefix
}
const documentCount = (connection.metadata?.documentCount as number) ?? 0
const containerTags = (
connection as Connection & { containerTags?: string[] }
).containerTags
const projectName =
containerTags && containerTags.length > 0 && containerTags[0]
? getProjectDisplayName(containerTags[0])
: null
return (
<div
className={cn(
"bg-[#14161A] border border-[rgba(82,89,102,0.2)] rounded-[12px] px-4 py-3",
"shadow-[0px_1px_2px_0px_rgba(0,43,87,0.1)]",
)}
>
<div className="flex flex-col gap-4">
{/* Main row */}
<div className="flex items-center gap-4">
<Icon className="size-6 shrink-0" />
<div className="flex-1 flex flex-col gap-1.5">
<div className="flex items-center gap-4">
<span
className={cn(
dmSans125ClassName(),
"font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
)}
>
{config.title}
</span>
<ConnectionStatusBadge connected={isConnected} />
</div>
<span
className={cn(
dmSans125ClassName(),
"font-medium text-[16px] tracking-[-0.16px] text-[#737373]",
)}
>
{connection.email || "Unknown"}
</span>
</div>
<button
type="button"
onClick={onDelete}
disabled={isDeleting || disabled}
className="text-[#737373] hover:text-red-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Delete connection"
>
<Trash2 className="size-[22px]" />
</button>
</div>
{/* Meta row */}
<div className="flex items-center gap-2 flex-wrap">
{projectName && (
<>
<span
className={cn(
dmSans125ClassName(),
"font-medium text-[14px] tracking-[-0.14px] text-[#737373]",
)}
>
Project: {projectName}
</span>
<div className="size-[3px] rounded-full bg-[#737373]" />
</>
)}
<span
className={cn(
dmSans125ClassName(),
"font-medium text-[14px] tracking-[-0.14px] text-[#737373]",
)}
>
Added: {formatRelativeTime(connection.createdAt)}
</span>
<div className="size-[3px] rounded-full bg-[#737373]" />
<span
className={cn(
dmSans125ClassName(),
"font-medium text-[14px] tracking-[-0.14px] text-[#737373]",
)}
>
{documentCount} {config.documentLabel} connected
</span>
</div>
</div>
</div>
)
}
function UpgradeOverlay({ onUpgrade }: { onUpgrade: () => void }) {
return (
<div className="absolute inset-0 flex items-center justify-center z-10">
<div className="flex flex-col items-center gap-4">
<div className="flex flex-col items-center gap-2">
<Zap className="size-6 text-[#737373]" />
<p
className={cn(
dmSans125ClassName(),
"font-medium text-[14px] tracking-[-0.14px] text-[#737373] text-center max-w-[184px]",
)}
>
<button
type="button"
onClick={onUpgrade}
className="underline hover:text-white transition-colors cursor-pointer"
>
Upgrade to Pro
</button>{" "}
to get Supermemory Connections
</p>
</div>
<div className="flex flex-col gap-2">
<FeatureItem text="Unlimited memories" />
<FeatureItem text="10 connections" />
<FeatureItem text="Advanced search" />
<FeatureItem text="Priority support" />
</div>
</div>
</div>
)
}
function FeatureItem({ text }: { text: string }) {
return (
<div className="flex items-center gap-2">
<Check className="size-4 shrink-0 text-[#4BA0FA]" />
<span
className={cn(
dmSans125ClassName(),
"text-[14px] tracking-[-0.14px] text-white",
)}
>
{text}
</span>
</div>
)
}
export default function ConnectionsMCP() {
const queryClient = useQueryClient()
const autumn = useCustomer()
const [isAddDocumentOpen, setIsAddDocumentOpen] = useState(false)
const [mcpModalOpen, setMcpModalOpen] = useState(false)
const projects = (queryClient.getQueryData<Project[]>(["projects"]) ||
[]) as Project[]
// Billing data
const {
data: status = {
consumer_pro: { allowed: false, status: null },
},
isLoading: isCheckingStatus,
} = fetchSubscriptionStatus(autumn, !autumn.isLoading)
const hasProProduct = status.consumer_pro?.status !== null
// Get connections data directly from autumn customer
const connectionsFeature = autumn.customer?.features?.connections
const connectionsUsed = connectionsFeature?.usage ?? 0
const connectionsLimit = connectionsFeature?.included_usage ?? 10
const canAddConnection = connectionsUsed < connectionsLimit
// Fetch connections
const {
data: connections = [],
isLoading: isLoadingConnections,
error: connectionsError,
} = 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 Connection[]
},
staleTime: 30 * 1000,
refetchInterval: 60 * 1000,
enabled: hasProProduct,
})
useEffect(() => {
if (connectionsError) {
toast.error("Failed to load connections", {
description:
connectionsError instanceof Error
? connectionsError.message
: "Unknown error",
})
}
}, [connectionsError])
// Delete connection mutation
const deleteConnectionMutation = useMutation({
mutationFn: async (connectionId: string) => {
await $fetch(`@delete/connections/${connectionId}`)
},
onSuccess: () => {
analytics.connectionDeleted()
toast.success(
"Connection removal has started. Supermemory will permanently delete the documents in the next few minutes.",
)
queryClient.invalidateQueries({ queryKey: ["connections"] })
},
onError: (error) => {
toast.error("Failed to remove connection", {
description: error instanceof Error ? error.message : "Unknown error",
})
},
})
// Upgrade handler
const handleUpgrade = async () => {
try {
await autumn.attach({
productId: "consumer_pro",
successUrl: "https://app.supermemory.ai/new/settings#connections",
})
window.location.reload()
} catch (error) {
console.error(error)
}
}
const isLoading = autumn.isLoading || isCheckingStatus
return (
<div className="flex flex-col gap-8 pt-4 w-full">
{/* Supermemory Connections Section */}
<div className="flex flex-col gap-4">
<SectionTitle badge={<ProBadge />}>
Supermemory Connections
</SectionTitle>
<ConnectionsCard className="relative">
{/* Blur overlay for free users */}
{!hasProProduct && !isLoading && (
<>
<div className="absolute inset-0 bg-[#14161A]/80 backdrop-blur-sm z-5" />
<UpgradeOverlay onUpgrade={handleUpgrade} />
</>
)}
<div
className={cn(
"flex flex-col gap-4",
!hasProProduct && !isLoading && "opacity-30 pointer-events-none",
)}
>
<div className="flex items-center justify-between">
<span
className={cn(
dmSans125ClassName(),
"font-semibold text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
)}
>
Connected to Supermemory
</span>
<span
className={cn(
dmSans125ClassName(),
"font-semibold text-[16px] tracking-[-0.16px] text-[#737373]",
)}
>
{connections.length}/{connectionsLimit} connections used
</span>
</div>
<div className="flex flex-col gap-4">
{isLoadingConnections ? (
<div className="flex items-center justify-center py-8">
<div className="size-6 border-2 border-[#737373] border-t-transparent rounded-full animate-spin" />
</div>
) : connections.length > 0 ? (
connections.map((connection) => (
<ConnectionRow
key={connection.id}
connection={connection}
onDelete={() =>
deleteConnectionMutation.mutate(connection.id)
}
isDeleting={deleteConnectionMutation.isPending}
disabled={!hasProProduct}
projects={projects}
/>
))
) : (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Zap className="size-6 text-[#737373] mb-2" />
<p
className={cn(
dmSans125ClassName(),
"font-medium text-[14px] text-[#737373]",
)}
>
No connections yet
</p>
<p
className={cn(
dmSans125ClassName(),
"font-medium text-[12px] text-[#737373]",
)}
>
Connect a service below to import your knowledge
</p>
</div>
)}
</div>
<PillButton
onClick={() => setIsAddDocumentOpen(true)}
disabled={!hasProProduct || !canAddConnection}
>
<Plus className="size-[10px] text-[#FAFAFA]" />
<span className="text-[14px] tracking-[-0.14px] text-[#FAFAFA] font-medium">
Connect knowledge bases
</span>
</PillButton>
</div>
</ConnectionsCard>
</div>
{/* Supermemory MCP Section */}
<div className="flex flex-col gap-4">
<SectionTitle>Supermemory MCP</SectionTitle>
<ConnectionsCard>
<div className="flex flex-col gap-4">
<p
className={cn(
dmSans125ClassName(),
"font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
)}
>
Connect your AI to create and use your memories via MCP.{" "}
<a
href="https://docs.supermemory.ai/supermemory-mcp/introduction"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-[#4BA0FA] transition-colors"
>
Learn more
</a>
</p>
<ConnectAIModal open={mcpModalOpen} onOpenChange={setMcpModalOpen}>
<PillButton onClick={() => setMcpModalOpen(true)}>
<Plus className="size-[10px] text-[#FAFAFA]" />
<span className="text-[14px] tracking-[-0.14px] text-[#FAFAFA] font-medium">
Connect your AI to Supermemory
</span>
</PillButton>
</ConnectAIModal>
</div>
</ConnectionsCard>
</div>
{/* Add Document Modal */}
<AddDocumentModal
isOpen={isAddDocumentOpen}
onClose={() => setIsAddDocumentOpen(false)}
defaultTab="connect"
/>
</div>
)
}

View file

@ -0,0 +1,761 @@
"use client"
import { dmSans125ClassName } from "@/utils/fonts"
import { analytics } from "@/lib/analytics"
import { cn } from "@lib/utils"
import { authClient } from "@lib/auth"
import { useAuth } from "@lib/auth-context"
import { generateId } from "@lib/generate-id"
import {
ADD_MEMORY_SHORTCUT_URL,
RAYCAST_EXTENSION_URL,
SEARCH_MEMORY_SHORTCUT_URL,
} from "@repo/lib/constants"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogPortal,
} from "@ui/components/dialog"
import { useMutation } from "@tanstack/react-query"
import { Check, Copy, Download, Key, Loader, Plus, Search } from "lucide-react"
import Image from "next/image"
import { useSearchParams } from "next/navigation"
import { useEffect, useId, useState } from "react"
import { toast } from "sonner"
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<p
className={cn(
dmSans125ClassName(),
"font-semibold text-[20px] tracking-[-0.2px] text-[#FAFAFA] px-2",
)}
>
{children}
</p>
)
}
function IntegrationCard({
children,
id,
}: {
children: React.ReactNode
id?: string
}) {
return (
<div
id={id}
className={cn(
"relative bg-[#14161A] rounded-[14px] p-6 w-full overflow-hidden",
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
)}
>
{children}
</div>
)
}
function PillButton({
children,
onClick,
className,
disabled,
}: {
children: React.ReactNode
onClick?: () => void
className?: string
disabled?: boolean
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={cn(
"relative flex items-center justify-center gap-2",
"bg-[#0D121A]",
"rounded-full h-11 px-4 flex-1",
"cursor-pointer transition-opacity hover:opacity-80",
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]",
"disabled:opacity-50 disabled:cursor-not-allowed",
dmSans125ClassName(),
className,
)}
>
{children}
</button>
)
}
function FeatureItem({ text }: { text: string }) {
return (
<div className="flex items-center gap-2">
<Check className="size-4 shrink-0 text-[#4BA0FA]" />
<span
className={cn(
dmSans125ClassName(),
"text-[14px] tracking-[-0.14px] text-white",
)}
>
{text}
</span>
</div>
)
}
function ChromeIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio="xMidYMid"
viewBox="0 0 190.5 190.5"
className={className}
>
<title>Google Chrome Icon</title>
<path
fill="#fff"
d="M95.252 142.873c26.304 0 47.627-21.324 47.627-47.628s-21.323-47.628-47.627-47.628-47.627 21.324-47.627 47.628 21.323 47.628 47.627 47.628z"
/>
<path
fill="#229342"
d="m54.005 119.07-41.24-71.43a95.227 95.227 0 0 0-.003 95.25 95.234 95.234 0 0 0 82.496 47.61l41.24-71.43v-.011a47.613 47.613 0 0 1-17.428 17.443 47.62 47.62 0 0 1-47.632.007 47.62 47.62 0 0 1-17.433-17.437z"
/>
<path
fill="#fbc116"
d="m136.495 119.067-41.239 71.43a95.229 95.229 0 0 0 82.489-47.622A95.24 95.24 0 0 0 190.5 95.248a95.237 95.237 0 0 0-12.772-47.623H95.249l-.01.007a47.62 47.62 0 0 1 23.819 6.372 47.618 47.618 0 0 1 17.439 17.431 47.62 47.62 0 0 1-.001 47.633z"
/>
<path
fill="#1a73e8"
d="M95.252 132.961c20.824 0 37.705-16.881 37.705-37.706S116.076 57.55 95.252 57.55 57.547 74.431 57.547 95.255s16.881 37.706 37.705 37.706z"
/>
<path
fill="#e33b2e"
d="M95.252 47.628h82.479A95.237 95.237 0 0 0 142.87 12.76 95.23 95.23 0 0 0 95.245 0a95.222 95.222 0 0 0-47.623 12.767 95.23 95.23 0 0 0-34.856 34.872l41.24 71.43.011.006a47.62 47.62 0 0 1-.015-47.633 47.61 47.61 0 0 1 41.252-23.815z"
/>
</svg>
)
}
function AppleShortcutsIcon() {
return (
<div className="relative size-10 shrink-0 rounded-lg overflow-hidden">
<Image
src="/images/ios-shortcuts.png"
alt="Apple Shortcuts"
width={40}
height={40}
className="object-cover"
/>
</div>
)
}
function RaycastIcon({ className }: { className?: string }) {
return (
<svg
width="24"
height="24"
viewBox="0 0 28 28"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<title>Raycast Icon</title>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M7 18.079V21L0 14L1.46 12.54L7 18.081V18.079ZM9.921 21H7L14 28L15.46 26.54L9.921 21ZM26.535 15.462L27.996 14L13.996 0L12.538 1.466L18.077 7.004H14.73L10.864 3.146L9.404 4.606L11.809 7.01H10.129V17.876H20.994V16.196L23.399 18.6L24.859 17.14L20.994 13.274V9.927L26.535 15.462ZM7.73 6.276L6.265 7.738L7.833 9.304L9.294 7.844L7.73 6.276ZM20.162 18.708L18.702 20.17L20.268 21.738L21.73 20.276L20.162 18.708ZM4.596 9.41L3.134 10.872L7 14.738V11.815L4.596 9.41ZM16.192 21.006H13.268L17.134 24.872L18.596 23.41L16.192 21.006Z"
fill="#FF6363"
/>
</svg>
)
}
export default function Integrations() {
const { org } = useAuth()
const searchParams = useSearchParams()
// iOS Shortcuts state
const [showApiKeyModal, setShowApiKeyModal] = useState(false)
const [apiKey, setApiKey] = useState<string>("")
const [copied, setCopied] = useState(false)
const [selectedShortcutType, setSelectedShortcutType] = useState<
"add" | "search" | null
>(null)
const apiKeyId = useId()
// Raycast state
const [showRaycastApiKeyModal, setShowRaycastApiKeyModal] = useState(false)
const [raycastApiKey, setRaycastApiKey] = useState<string>("")
const [raycastCopied, setRaycastCopied] = useState(false)
const [hasTriggeredRaycast, setHasTriggeredRaycast] = useState(false)
const raycastApiKeyId = useId()
const handleCopyApiKey = async (key: string, isRaycast = false) => {
try {
await navigator.clipboard.writeText(key)
if (isRaycast) {
setRaycastCopied(true)
setTimeout(() => setRaycastCopied(false), 2000)
} else {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
toast.success("API key copied to clipboard!")
} catch {
toast.error("Failed to copy API key")
}
}
const createApiKeyMutation = useMutation({
mutationFn: async () => {
const res = await authClient.apiKey.create({
metadata: {
organizationId: org?.id,
type: "ios-shortcut",
},
name: `ios-${generateId().slice(0, 8)}`,
prefix: `sm_${org?.id}_`,
})
return res.key
},
onSuccess: (key) => {
setApiKey(key)
setShowApiKeyModal(true)
setCopied(false)
handleCopyApiKey(key)
},
onError: (error) => {
toast.error("Failed to create API key", {
description: error instanceof Error ? error.message : "Unknown error",
})
},
})
const createRaycastApiKeyMutation = useMutation({
mutationFn: async () => {
if (!org?.id) {
throw new Error("Organization ID is required")
}
const res = await authClient.apiKey.create({
metadata: {
organizationId: org.id,
type: "raycast-extension",
},
name: `raycast-${generateId().slice(0, 8)}`,
prefix: `sm_${org.id}_`,
})
return res.key
},
onSuccess: (key) => {
setRaycastApiKey(key)
setShowRaycastApiKeyModal(true)
setRaycastCopied(false)
handleCopyApiKey(key, true)
},
onError: (error) => {
toast.error("Failed to create Raycast API key", {
description: error instanceof Error ? error.message : "Unknown error",
})
},
})
useEffect(() => {
const qParam = searchParams.get("q")
if (
qParam === "raycast" &&
!hasTriggeredRaycast &&
!createRaycastApiKeyMutation.isPending &&
org?.id
) {
setHasTriggeredRaycast(true)
createRaycastApiKeyMutation.mutate()
}
}, [searchParams, hasTriggeredRaycast, createRaycastApiKeyMutation, org])
const handleChromeInstall = () => {
window.open(
"https://chromewebstore.google.com/detail/supermemory/afpgkkipfdpeaflnpoaffkcankadgjfc",
"_blank",
"noopener,noreferrer",
)
analytics.extensionInstallClicked()
}
const handleShortcutClick = (shortcutType: "add" | "search") => {
setSelectedShortcutType(shortcutType)
createApiKeyMutation.mutate()
}
const handleOpenShortcut = () => {
if (!selectedShortcutType) {
toast.error("No shortcut type selected")
return
}
if (selectedShortcutType === "add") {
window.open(ADD_MEMORY_SHORTCUT_URL, "_blank")
} else if (selectedShortcutType === "search") {
window.open(SEARCH_MEMORY_SHORTCUT_URL, "_blank")
}
}
const handleRaycastClick = () => {
createRaycastApiKeyMutation.mutate()
}
const handleRaycastInstall = () => {
window.open(RAYCAST_EXTENSION_URL, "_blank")
analytics.extensionInstallClicked()
}
const handleDialogClose = (open: boolean) => {
setShowApiKeyModal(open)
if (!open) {
setSelectedShortcutType(null)
setApiKey("")
setCopied(false)
}
}
const handleRaycastDialogClose = (open: boolean) => {
setShowRaycastApiKeyModal(open)
if (!open) {
setRaycastApiKey("")
setRaycastCopied(false)
}
}
return (
<div className="flex flex-col gap-4 pt-4 w-full">
<SectionTitle>Integrations</SectionTitle>
<IntegrationCard id="chrome-extension-card">
<div className="flex flex-col gap-6">
<div id="chrome-extension-header" className="flex items-center gap-4">
<ChromeIcon className="shrink-0 w-10 h-10" />
<div className="flex flex-col gap-1.5">
<p
className={cn(
dmSans125ClassName(),
"font-semibold text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
)}
>
Chrome extension
</p>
<p
className={cn(
dmSans125ClassName(),
"font-medium text-[16px] tracking-[-0.16px] text-[#737373]",
)}
>
Save any webpage directly from your browser
</p>
</div>
</div>
<div id="chrome-extension-cta" className="flex gap-4">
<PillButton onClick={handleChromeInstall}>
<Download className="size-4 text-[#FAFAFA]" />
<span className="text-[14px] tracking-[-0.14px] text-[#FAFAFA] font-medium">
Add to Chrome
</span>
</PillButton>
</div>
<div
id="chrome-extension-features"
className="grid grid-cols-1 sm:grid-cols-2 gap-2"
>
<FeatureItem text="Import all Twitter bookmarks" />
<FeatureItem text="Sync ChatGPT memories" />
<FeatureItem text="Save any webpage" />
<FeatureItem text="One time setup" />
</div>
</div>
</IntegrationCard>
<IntegrationCard id="apple-shortcuts-card">
<div className="flex flex-col gap-6">
<div id="apple-shortcuts-header" className="flex items-center gap-4">
<AppleShortcutsIcon />
<div className="flex flex-col gap-1.5">
<p
className={cn(
dmSans125ClassName(),
"font-semibold text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
)}
>
Apple shortcuts
</p>
<p
className={cn(
dmSans125ClassName(),
"font-medium text-[16px] tracking-[-0.16px] text-[#737373]",
)}
>
Add memories directly from iPhone, iPad or Mac
</p>
</div>
</div>
<div id="apple-shortcuts-cta" className="flex gap-4">
<PillButton
onClick={() => handleShortcutClick("add")}
disabled={createApiKeyMutation.isPending}
>
{createApiKeyMutation.isPending &&
selectedShortcutType === "add" ? (
<Loader className="size-4 text-[#FAFAFA] animate-spin" />
) : (
<Plus className="size-4 text-[#FAFAFA]" />
)}
<span className="text-[14px] tracking-[-0.14px] text-[#FAFAFA] font-medium">
{createApiKeyMutation.isPending &&
selectedShortcutType === "add"
? "Creating..."
: "Add memory shortcut"}
</span>
</PillButton>
<PillButton
onClick={() => handleShortcutClick("search")}
disabled={createApiKeyMutation.isPending}
>
{createApiKeyMutation.isPending &&
selectedShortcutType === "search" ? (
<Loader className="size-4 text-[#FAFAFA] animate-spin" />
) : (
<Search className="size-4 text-[#FAFAFA]" />
)}
<span className="text-[14px] tracking-[-0.14px] text-[#FAFAFA] font-medium">
{createApiKeyMutation.isPending &&
selectedShortcutType === "search"
? "Creating..."
: "Search memory shortcut"}
</span>
</PillButton>
</div>
</div>
</IntegrationCard>
<IntegrationCard id="raycast-extension-card">
<div className="flex flex-col gap-6">
<div
id="raycast-extension-header"
className="flex items-center gap-4"
>
<RaycastIcon className="shrink-0 w-10 h-10" />
<div className="flex flex-col gap-1.5">
<p
className={cn(
dmSans125ClassName(),
"font-semibold text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
)}
>
Raycast extension
</p>
<p
className={cn(
dmSans125ClassName(),
"font-medium text-[16px] tracking-[-0.16px] text-[#737373]",
)}
>
Add and search memories from Mac and Windows
</p>
</div>
</div>
<div id="raycast-extension-cta" className="flex gap-4">
<PillButton
onClick={handleRaycastClick}
disabled={createRaycastApiKeyMutation.isPending}
>
{createRaycastApiKeyMutation.isPending ? (
<Loader className="size-4 text-[#FAFAFA] animate-spin" />
) : (
<Key className="size-4 text-[#FAFAFA]" />
)}
<span className="text-[14px] tracking-[-0.14px] text-[#FAFAFA] font-medium">
{createRaycastApiKeyMutation.isPending
? "Generating..."
: "Get API key"}
</span>
</PillButton>
<PillButton onClick={handleRaycastInstall}>
<Download className="size-4 text-[#FAFAFA]" />
<span className="text-[14px] tracking-[-0.14px] text-[#FAFAFA] font-medium">
Install extension
</span>
</PillButton>
</div>
</div>
</IntegrationCard>
<Dialog open={showApiKeyModal} onOpenChange={handleDialogClose}>
<DialogPortal>
<DialogContent
id="ios-shortcuts-modal"
className="bg-[#14161A] border border-white/10 text-[#FAFAFA] md:max-w-md z-100"
>
<DialogHeader>
<DialogTitle
className={cn(
dmSans125ClassName(),
"text-[#FAFAFA] text-lg font-semibold",
)}
>
Setup Apple Shortcut
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div id="ios-shortcuts-api-key-section" className="space-y-2">
<label
htmlFor={apiKeyId}
className={cn(
dmSans125ClassName(),
"text-sm font-medium text-[#737373]",
)}
>
Your API Key
</label>
<div className="flex items-center gap-2">
<input
id={apiKeyId}
type="text"
value={apiKey}
readOnly
className={cn(
"flex-1 bg-[#0D121A] border border-white/10 rounded-lg px-3 py-2 text-sm text-[#FAFAFA] font-mono",
dmSans125ClassName(),
)}
/>
<button
type="button"
onClick={() => handleCopyApiKey(apiKey)}
className="p-2 rounded-lg bg-[#0D121A] border border-white/10 text-[#737373] hover:text-[#FAFAFA] transition-colors"
>
{copied ? (
<Check className="h-4 w-4 text-[#4BA0FA]" />
) : (
<Copy className="h-4 w-4" />
)}
</button>
</div>
</div>
<div id="ios-shortcuts-steps" className="space-y-3">
<h4
className={cn(
dmSans125ClassName(),
"text-sm font-medium text-[#737373]",
)}
>
Follow these steps:
</h4>
<div className="space-y-2">
<div className="flex items-start gap-3">
<div className="shrink-0 w-6 h-6 bg-[#4BA0FA]/20 text-[#4BA0FA] rounded-full flex items-center justify-center text-xs font-medium">
1
</div>
<p
className={cn(
dmSans125ClassName(),
"text-sm text-[#737373]",
)}
>
Click "Add to Shortcuts" below to open the shortcut
</p>
</div>
<div className="flex items-start gap-3">
<div className="shrink-0 w-6 h-6 bg-[#4BA0FA]/20 text-[#4BA0FA] rounded-full flex items-center justify-center text-xs font-medium">
2
</div>
<p
className={cn(
dmSans125ClassName(),
"text-sm text-[#737373]",
)}
>
Paste your API key when prompted
</p>
</div>
<div className="flex items-start gap-3">
<div className="shrink-0 w-6 h-6 bg-[#4BA0FA]/20 text-[#4BA0FA] rounded-full flex items-center justify-center text-xs font-medium">
3
</div>
<p
className={cn(
dmSans125ClassName(),
"text-sm text-[#737373]",
)}
>
Start using your shortcut!
</p>
</div>
</div>
</div>
<div className="flex gap-2 pt-2">
<button
type="button"
onClick={handleOpenShortcut}
disabled={!selectedShortcutType}
className={cn(
"flex-1 flex items-center justify-center gap-2",
"bg-[#4BA0FA] hover:bg-[#4BA0FA]/90 text-white",
"rounded-lg h-11 px-4 font-medium text-sm",
"disabled:opacity-50 disabled:cursor-not-allowed",
"transition-colors",
dmSans125ClassName(),
)}
>
<Image
src="/images/ios-shortcuts.png"
alt="iOS Shortcuts"
width={16}
height={16}
/>
Add to Shortcuts
</button>
</div>
</div>
</DialogContent>
</DialogPortal>
</Dialog>
<Dialog
open={showRaycastApiKeyModal}
onOpenChange={handleRaycastDialogClose}
>
<DialogPortal>
<DialogContent
id="raycast-api-key-modal"
className="bg-[#14161A] border border-white/10 text-[#FAFAFA] md:max-w-md z-100"
>
<DialogHeader>
<DialogTitle
className={cn(
dmSans125ClassName(),
"text-[#FAFAFA] text-lg font-semibold",
)}
>
Setup Raycast Extension
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div id="raycast-api-key-section" className="space-y-2">
<label
htmlFor={raycastApiKeyId}
className={cn(
dmSans125ClassName(),
"text-sm font-medium text-[#737373]",
)}
>
Your Raycast API Key
</label>
<div className="flex items-center gap-2">
<input
id={raycastApiKeyId}
type="text"
value={raycastApiKey}
readOnly
className={cn(
"flex-1 bg-[#0D121A] border border-white/10 rounded-lg px-3 py-2 text-sm text-[#FAFAFA] font-mono",
dmSans125ClassName(),
)}
/>
<button
type="button"
onClick={() => handleCopyApiKey(raycastApiKey, true)}
className="p-2 rounded-lg bg-[#0D121A] border border-white/10 text-[#737373] hover:text-[#FAFAFA] transition-colors"
>
{raycastCopied ? (
<Check className="h-4 w-4 text-[#4BA0FA]" />
) : (
<Copy className="h-4 w-4" />
)}
</button>
</div>
</div>
<div id="raycast-steps" className="space-y-3">
<h4
className={cn(
dmSans125ClassName(),
"text-sm font-medium text-[#737373]",
)}
>
Follow these steps:
</h4>
<div className="space-y-2">
<div className="flex items-start gap-3">
<div className="shrink-0 w-6 h-6 bg-[#FF6363]/20 text-[#FF6363] rounded-full flex items-center justify-center text-xs font-medium">
1
</div>
<p
className={cn(
dmSans125ClassName(),
"text-sm text-[#737373]",
)}
>
Install the Raycast extension from the Raycast Store
</p>
</div>
<div className="flex items-start gap-3">
<div className="shrink-0 w-6 h-6 bg-[#FF6363]/20 text-[#FF6363] rounded-full flex items-center justify-center text-xs font-medium">
2
</div>
<p
className={cn(
dmSans125ClassName(),
"text-sm text-[#737373]",
)}
>
Open Raycast preferences and paste your API key
</p>
</div>
<div className="flex items-start gap-3">
<div className="shrink-0 w-6 h-6 bg-[#FF6363]/20 text-[#FF6363] rounded-full flex items-center justify-center text-xs font-medium">
3
</div>
<p
className={cn(
dmSans125ClassName(),
"text-sm text-[#737373]",
)}
>
Use "Add Memory" or "Search Memories" commands!
</p>
</div>
</div>
</div>
<div className="flex gap-2 pt-2">
<button
type="button"
onClick={handleRaycastInstall}
className={cn(
"flex-1 flex items-center justify-center gap-2",
"bg-[#FF6363] hover:bg-[#FF6363]/90 text-white",
"rounded-lg h-11 px-4 font-medium text-sm",
"transition-colors",
dmSans125ClassName(),
)}
>
<RaycastIcon className="size-4" />
Install Extension
</button>
</div>
</div>
</DialogContent>
</DialogPortal>
</Dialog>
</div>
)
}

View file

@ -0,0 +1,217 @@
"use client"
import { dmSans125ClassName } from "@/utils/fonts"
import { cn } from "@lib/utils"
import { ArrowUpRight } from "lucide-react"
const FAQS = [
{
question: "How do I upgrade to Pro?",
answer:
'Go to the Billing tab in settings and click "Upgrade to Pro". You\'ll be redirected to our secure payment processor.',
},
{
question: "What's included in the Pro plan?",
answer:
'Go to the Billing tab in settings and click "Upgrade to Pro". You\'ll be redirected to our secure payment processor.',
},
{
question: "How do connections work?",
answer:
"Connections let you sync documents from Google Drive, Notion, and OneDrive automatically. supermemory will index and make them searchable.",
},
{
question: "Can I cancel my subscription anytime?",
answer:
"Yes. You can cancel anytime from the Billing tab. Your Pro features will remain active until the end of your billing period.",
},
]
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<p
className={cn(
dmSans125ClassName(),
"font-semibold text-[20px] tracking-[-0.2px] text-[#FAFAFA] px-2",
)}
>
{children}
</p>
)
}
function SupportCard({ children }: { children: React.ReactNode }) {
return (
<div
className={cn(
"relative bg-[#14161A] rounded-[14px] p-6 w-full overflow-hidden",
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
)}
>
{children}
</div>
)
}
function PillButton({
children,
onClick,
className,
}: {
children: React.ReactNode
onClick?: () => void
className?: string
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"relative flex items-center justify-center gap-2",
"bg-[#0D121A]",
"rounded-full h-11 px-4 flex-1",
"cursor-pointer transition-opacity hover:opacity-80",
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]",
dmSans125ClassName(),
className,
)}
>
{children}
</button>
)
}
export default function Support() {
const handleMessageOnX = () => {
window.open("https://x.com/supermemory", "_blank", "noopener,noreferrer")
}
const handleSendEmail = () => {
window.location.href = "mailto:support@supermemory.com"
}
const handleJoinDiscord = () => {
window.open(
"https://supermemory.link/discord",
"_blank",
"noopener,noreferrer",
)
}
const handleShareFeedback = () => {
window.open("https://x.com/supermemory", "_blank", "noopener,noreferrer")
}
return (
<div className="flex flex-col gap-8 pt-4 w-full">
{/* Support & Help Section */}
<section className="flex flex-col gap-4">
<SectionTitle>Support &amp; Help</SectionTitle>
<SupportCard>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<p
className={cn(
dmSans125ClassName(),
"font-normal text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
)}
>
Get help
</p>
<p
className={cn(
dmSans125ClassName(),
"font-medium text-[16px] tracking-[-0.16px] text-[#737373]",
)}
>
Need assistance? We're here to help! Choose the best way to
reach us.
</p>
</div>
<div className="flex flex-col sm:flex-row gap-4">
<PillButton onClick={handleMessageOnX}>
<span className="text-[14px] tracking-[-0.14px] text-[#FAFAFA] font-medium">
Message us on X
</span>
<ArrowUpRight className="size-4 text-[#FAFAFA]" />
</PillButton>
<PillButton onClick={handleJoinDiscord}>
<span className="text-[14px] tracking-[-0.14px] text-[#FAFAFA] font-medium">
Join our Discord
</span>
<ArrowUpRight className="size-4 text-[#FAFAFA]" />
</PillButton>
<PillButton onClick={handleSendEmail}>
<span className="text-[14px] tracking-[-0.14px] text-[#FAFAFA] font-medium">
Send us an email
</span>
<ArrowUpRight className="size-4 text-[#FAFAFA]" />
</PillButton>
</div>
</div>
</SupportCard>
</section>
{/* FAQ Section */}
<section className="flex flex-col gap-4">
<SectionTitle>Frequently Asked Questions</SectionTitle>
<SupportCard>
<div className="flex flex-col gap-6">
{FAQS.map((faq, index) => (
<div key={faq.question}>
<div className="flex flex-col gap-1">
<p
className={cn(
dmSans125ClassName(),
"font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
)}
>
{faq.question}
</p>
<p
className={cn(
dmSans125ClassName(),
"font-medium text-[16px] tracking-[-0.16px] text-[#737373]",
)}
>
{faq.answer}
</p>
</div>
{index < FAQS.length - 1 && (
<div className="bg-[#1F2125] h-px w-full mt-6" />
)}
</div>
))}
</div>
</SupportCard>
</section>
{/* Feedback Section */}
<section className="flex flex-col gap-4">
<SectionTitle>Feedback &amp; Feature Requests</SectionTitle>
<SupportCard>
<div className="flex flex-col gap-4">
<p
className={cn(
dmSans125ClassName(),
"font-normal text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
)}
>
Have ideas for new features or improvements? We'd love to hear
from you!
</p>
<PillButton
onClick={handleShareFeedback}
className="w-full flex-none"
>
<span className="text-[14px] tracking-[-0.14px] text-[#FAFAFA] font-medium">
Share your feedback on X/Twitter
</span>
<ArrowUpRight className="size-4 text-[#FAFAFA]" />
</PillButton>
</div>
</SupportCard>
</section>
</div>
)
}

View file

@ -0,0 +1,78 @@
"use client"
import { useQuery } from "@tanstack/react-query"
export function isYouTubeUrl(url: string | undefined | null): boolean {
if (!url) return false
return (
url.includes("youtube.com") ||
url.includes("youtu.be") ||
url.includes("m.youtube.com")
)
}
export function extractYouTubeVideoId(
url: string | undefined | null,
): string | null {
if (!url) return null
// Handle youtu.be format
const youtuBeMatch = url.match(/(?:youtu\.be\/)([a-zA-Z0-9_-]{11})/)
if (youtuBeMatch?.[1]) return youtuBeMatch[1]
// Handle youtube.com/watch?v= format
const watchMatch = url.match(/(?:youtube\.com\/watch\?v=)([a-zA-Z0-9_-]{11})/)
if (watchMatch?.[1]) return watchMatch[1]
// Handle youtube.com/embed/ format
const embedMatch = url.match(/(?:youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/)
if (embedMatch?.[1]) return embedMatch[1]
// Handle m.youtube.com format
const mobileMatch = url.match(
/(?:m\.youtube\.com\/watch\?v=)([a-zA-Z0-9_-]{11})/,
)
if (mobileMatch?.[1]) return mobileMatch[1]
return null
}
export function useYouTubeChannelName(url: string | undefined | null) {
const videoId = extractYouTubeVideoId(url)
const videoUrl = videoId
? `https://www.youtube.com/watch?v=${videoId}`
: url || ""
return useQuery({
queryKey: ["youtube-channel", videoUrl],
queryFn: async () => {
if (!videoUrl) return null
try {
const response = await fetch(
`https://www.youtube.com/oembed?url=${encodeURIComponent(videoUrl)}&format=json`,
)
if (!response.ok) return null
const data = (await response.json()) as { author_name?: string }
return data.author_name || null
} catch {
return null
}
},
enabled: !!videoUrl && isYouTubeUrl(url),
staleTime: 1000 * 60 * 60 * 24,
retry: 1,
})
}
export function getAbsoluteUrl(url: string): string {
try {
const urlObj = new URL(url)
return urlObj.host.replace(/^www\./, "")
} catch {
const match = url.match(/^https?:\/\/([^\/]+)/)
const host = match?.[1] ?? url.replace(/^https?:\/\//, "")
return host.replace(/^www\./, "")
}
}

View file

@ -0,0 +1,150 @@
"use client"
interface BgGradProps {
size?: number
className?: string
}
function BgGrad({ size = 400, className = "" }: BgGradProps) {
return (
<div
className={`relative ${className}`}
data-name="bg-grad"
style={{ width: `${size}px`, height: `${size}px` }}
>
{/* Large blue orb - right */}
<div
className="absolute bg-[#1410ff] filter rounded-full z-0"
style={{
height: `${(341.891 * size) / 400}px`,
left: `${(161.86 * size) / 400}px`,
top: `${(13.1 * size) / 400}px`,
width: `${(204.976 * size) / 400}px`,
filter: `blur(${(22.836 * size) / 400}px)`,
}}
data-name="4"
/>
{/* Large blue orb - left */}
<div
className="absolute bg-[#1410ff] filter rounded-full z-0"
style={{
height: `${(341.891 * size) / 400}px`,
left: `${(17.16 * size) / 400}px`,
top: `${(-0.01 * size) / 400}px`,
width: `${(204.976 * size) / 400}px`,
filter: `blur(${(22.836 * size) / 400}px)`,
}}
data-name="4"
/>
{/* Rotated blue orb - top left */}
<div
className="absolute flex items-center justify-center z-10"
style={{
height: `${(163.918 * size) / 400}px`,
left: `${(12.75 * size) / 400}px`,
top: `${(70.19 * size) / 400}px`,
width: `${(345.985 * size) / 400}px`,
}}
>
<div className="flex-none rotate-[11.06deg]">
<div
className="bg-[#0090ff] filter rounded-full"
style={{
height: `${(163.918 * size) / 400}px`,
width: `${(345.985 * size) / 400}px`,
filter: `blur(${(17.127 * size) / 400}px)`,
}}
data-name="3"
/>
</div>
</div>
{/* Rotated blue orb - top right */}
<div
className="absolute flex items-center justify-center z-20"
style={{
height: `${(103.099 * size) / 400}px`,
left: `${(195 * size) / 400}px`,
top: `${(84.54 * size) / 400}px`,
width: `${(159.018 * size) / 400}px`,
}}
>
<div className="flex-none rotate-[32.89deg]">
<div
className="bg-[#0099ff] filter rounded-full"
style={{
height: `${(103.099 * size) / 400}px`,
width: `${(159.018 * size) / 400}px`,
filter: `blur(${(14.273 * size) / 400}px)`,
}}
data-name="2"
/>
</div>
</div>
{/* Rotated blue orb - bottom left */}
<div
className="absolute flex items-center justify-center z-20"
style={{
height: `${(103.099 * size) / 400}px`,
left: "0px",
top: `${(81.05 * size) / 400}px`,
width: `${(159.018 * size) / 400}px`,
}}
>
<div className="flex-none rotate-[147.11deg]">
<div
className="bg-[#0099ff] filter rounded-full"
style={{
height: `${(103.099 * size) / 400}px`,
width: `${(159.018 * size) / 400}px`,
filter: `blur(${(14.273 * size) / 400}px)`,
}}
data-name="2"
/>
</div>
</div>
{/* Central rotated orb */}
<div
className="absolute flex items-center justify-center z-30"
style={{
height: `${(220.17 * size) / 400}px`,
left: `${(85.76 * size) / 400}px`,
top: `${(110.88 * size) / 400}px`,
width: `${(216.8 * size) / 400}px`,
}}
>
<div className="flex-none rotate-[180deg]">
<div
className="filter rounded-full"
style={{
height: `${(220.17 * size) / 400}px`,
width: `${(216.8 * size) / 400}px`,
filter: `blur(${(8.564 * size) / 400}px)`,
background: "linear-gradient(186deg, #FFF 4.91%, #124DFF 61.9%)",
}}
data-name="1"
/>
</div>
</div>
{/* Bottom blue orb */}
<div
className="absolute bg-[#47a8fd] filter rounded-full z-40"
style={{
height: `${(74.371 * size) / 400}px`,
left: `${(103.05 * size) / 400}px`,
top: `${(228.82 * size) / 400}px`,
width: `${(153.608 * size) / 400}px`,
filter: `blur(${(17.127 * size) / 400}px)`,
}}
data-name="0"
/>
</div>
)
}
export default BgGrad

View file

@ -0,0 +1,42 @@
"use client"
import BgGrad from "./bg-grad"
import { motion } from "motion/react"
interface NovaOrbProps {
size?: number
className?: string
}
function NovaOrb({ size = 200, className = "" }: NovaOrbProps) {
return (
<div
className={`flex items-center justify-center ${className} blur-[6px]`}
style={{ width: `${size}px`, height: `${size}px` }}
>
<div
className="rounded-full relative overflow-hidden"
style={{
width: size,
height: size,
boxShadow: `${(1 * size) / 30}px ${(2 * size) / 30}px ${(4 * size) / 30}px 0 #0A0E14 inset, 0 ${(18.462 * size) / 30}px ${(5.192 * size) / 30}px 0 rgba(41, 95, 255, 0.00), 0 ${(12.115 * size) / 30}px ${(4.615 * size) / 30}px 0 rgba(41, 95, 255, 0.01), 0 ${(6.923 * size) / 30}px ${(4.038 * size) / 30}px 0 rgba(41, 95, 255, 0.05), 0 ${(2.885 * size) / 30}px ${(2.885 * size) / 30}px 0 rgba(41, 95, 255, 0.09), 0 ${(0.577 * size) / 30}px ${(1.731 * size) / 30}px 0 rgba(41, 95, 255, 0.10)`,
}}
>
<div className="rotate-[30.76deg] z-[-1] absolute top-[-30%] left-[-60%]">
<motion.div
animate={{ rotate: 360 }}
transition={{
duration: 12,
ease: "linear",
repeat: Number.POSITIVE_INFINITY,
}}
>
<BgGrad size={size * 1.8} />
</motion.div>
</div>
</div>
</div>
)
}
export default NovaOrb

View file

@ -39,17 +39,9 @@ import { useState } from "react"
import { useProjectMutations } from "@/hooks/use-project-mutations"
import { useProjectName } from "@/hooks/use-project-name"
import { useProject } from "@/stores"
import type { Project } from "@repo/lib/types"
import { CreateProjectDialog } from "./create-project-dialog"
interface Project {
id: string
name: string
containerTag: string
createdAt: string
updatedAt: string
isExperimental?: boolean
}
export function ProjectSelector() {
const [isOpen, setIsOpen] = useState(false)
const [showCreateDialog, setShowCreateDialog] = useState(false)

View file

@ -0,0 +1,24 @@
"use client"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"
import { useState } from "react"
export const QueryProvider = ({ children }: { children: React.ReactNode }) => {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
refetchIntervalInBackground: false,
refetchOnWindowFocus: false,
staleTime: 60 * 1000,
},
},
}),
)
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
}

View file

@ -0,0 +1,98 @@
"use client"
import { motion, useReducedMotion, type Variants } from "motion/react"
type NovaPathLoaderProps = {
size?: number // px
colorClassName?: string
label?: string
className?: string
}
// full SVG path data from nova-2d-anim.svg
const PATH_RIGHT =
"M3.03472 6.05861L6.8539 9.91021H1.96777V11.9737H8.3006V17.4781H10.3467V11.5057C10.3467 10.8713 10.0963 10.2621 9.65119 9.81327L4.48145 4.59961L3.03472 6.05861Z"
const PATH_LEFT =
"M12.6994 9.02793V3.52344H10.6533V9.49591C10.6533 10.1302 10.9037 10.7395 11.3488 11.1883L16.5197 16.4032L17.9665 14.9441L14.1473 11.0926H19.0334V9.02914L12.6994 9.02793Z"
// animation for stroke draw
const strokeVariants: Variants = {
hidden: { pathLength: 0, opacity: 0.2 },
visible: (i: number) => ({
pathLength: [0, 1],
opacity: [0.2, 1],
transition: {
duration: 0.9,
repeat: Number.POSITIVE_INFINITY,
repeatType: "reverse",
ease: "easeInOut",
delay: i * 0.18,
},
}),
static: { pathLength: 1, opacity: 0.7 },
}
export function SuperLoader({
size = 42,
colorClassName = "text-sky-400",
label = "Loading...",
className = "",
}: NovaPathLoaderProps) {
const prefersReducedMotion = useReducedMotion()
const animateVariant = prefersReducedMotion ? "static" : "visible"
return (
<div
role="status"
aria-label={label}
className={`inline-flex flex-col items-center gap-2 ${className}`}
style={{ width: size + 10 }}
>
<motion.svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 21 21"
width={size}
height={size}
className={`shrink-0 ${colorClassName}`}
>
<title>Loading...</title>
{/* Right path */}
<motion.path
d={PATH_RIGHT}
fill="none"
stroke="currentColor"
strokeWidth={1.4}
strokeLinecap="round"
strokeLinejoin="round"
initial="hidden"
animate={animateVariant}
variants={strokeVariants}
custom={0}
/>
{/* Left path */}
<motion.path
d={PATH_LEFT}
fill="none"
stroke="currentColor"
strokeWidth={1.4}
strokeLinecap="round"
strokeLinejoin="round"
initial="hidden"
animate={animateVariant}
variants={strokeVariants}
custom={1}
/>
</motion.svg>
<span
className="text-xs font-medium text-slate-500"
style={{ fontSize: size * 0.25 }}
>
{label}
</span>
</div>
)
}

View file

@ -710,7 +710,7 @@ export function ChatMessages() {
className="text-muted-foreground"
/>
<span className="text-xs text-muted-foreground">
{modelNames[selectedModel]}
{/*{modelNames[selectedModel]}*/}
</span>
</div>
{status === "streaming" || status === "submitted" ? (

View file

@ -48,18 +48,10 @@ import { toast } from "sonner"
import type { z } from "zod"
import { analytics } from "@/lib/analytics"
import { useProject } from "@/stores"
import type { Project } from "@repo/lib/types"
type Connection = z.infer<typeof ConnectionResponseSchema>
interface Project {
id: string
name: string
containerTag: string
createdAt: string
updatedAt: string
isExperimental?: boolean
}
const CONNECTORS = {
"google-drive": {
title: "Google Drive",

View file

@ -19,6 +19,7 @@ import { useState } from "react"
import { toast } from "sonner"
import { analytics } from "@/lib/analytics"
import { $fetch } from "@repo/lib/api"
import type { Project } from "@repo/lib/types"
import { useQuery } from "@tanstack/react-query"
const clients = {
@ -33,15 +34,6 @@ const clients = {
"claude-code": "Claude Code",
} as const
interface Project {
id: string
name: string
containerTag: string
createdAt: string
updatedAt: string
isExperimental?: boolean
}
export function InstallationDialogContent() {
const [client, setClient] = useState<keyof typeof clients>("cursor")
const [selectedProject, setSelectedProject] = useState<string | null>("none")

View file

@ -0,0 +1,113 @@
"use client"
import { Button } from "@ui/components/button"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/utils/fonts"
import Image from "next/image"
interface XBookmarksDetailViewProps {
onBack: () => void
}
const steps = [
{
number: 1,
title: "Install the Chrome Extension and login with your supermemory",
image: "/onboarding/chrome-ext-1.png",
},
{
number: 2,
title: "Visit the bookmarks tab on X and one-click import your bookmarks",
image: "/onboarding/chrome-ext-2.png",
},
{
number: 3,
title: "Talk to your bookmarks via Nova & see it in your memory graph",
image: "/onboarding/chrome-ext-3.png",
},
]
export function XBookmarksDetailView({
onBack,
}: XBookmarksDetailViewProps) {
const handleInstall = () => {
window.open(
"https://chromewebstore.google.com/detail/supermemory/afpgkkipfdpeaflnangednailhoegogi",
"_blank",
)
}
return (
<div className="flex flex-col h-full p-8">
<div className="mb-6">
<Button
variant="link"
className="text-white hover:text-gray-300 p-0 hover:no-underline cursor-pointer"
onClick={onBack}
>
← Back
</Button>
</div>
<div className="flex flex-col items-start justify-start flex-1">
<div>
<h1 className="text-white text-[20px] font-medium mb-3 text-start">
Import your X bookmarks via the Chrome Extension
</h1>
<p
className={cn(
"text-[#8B8B8B] text-sm mb-6 text-start max-w-2xl",
dmSansClassName(),
)}
>
Bring your X bookmarks into Supermemory in just a few clicks.
They'll be automatically embedded so you can easily find what you
need, right when you need it.
</p>
<div className="grid grid-cols-3 gap-4 mb-6 max-w-5xl w-full">
{steps.map((step) => (
<div
key={step.number}
className="flex flex-col items-center text-center bg-[#080B0F] p-3 rounded-[10px]"
>
<div className="rounded-2xl p-6 mb-3 w-full aspect-4/4 flex items-center justify-center relative overflow-hidden">
<Image
src={step.image}
alt={`Step ${step.number}`}
fill
className="object-cover"
unoptimized
/>
</div>
<div className="flex flex-col items-start justify-start">
<div className="mb-2">
<span className="text-white text-sm font-medium">
Step {step.number}
</span>
</div>
<p
className={cn(
"text-[#8B8B8B] text-sm text-start",
dmSansClassName(),
)}
>
{step.title}
</p>
</div>
</div>
))}
</div>
</div>
<Button
className="rounded-xl px-4 py-2 text-white h-10 cursor-pointer mx-auto bg-black"
onClick={handleInstall}
>
Install Chrome Extension →
</Button>
</div>
</div>
)
}

View file

@ -1,10 +1,90 @@
@import url("https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&display=swap");
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@theme {
--color-onboarding: #525966;
}
:root {
--color-placeholder-onboarding: #525966;
}
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: #293952 transparent;
}
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
.sm-tweet-theme .react-tweet-theme {
--tweet-container-margin: 0px;
font-size: inherit !important;
}
.sm-tweet-theme .react-tweet-theme * {
min-width: 0 !important;
}
.sm-tweet-theme .tweet-header-module__A9EVQG__authorFollow {
display: none;
}
.tweet-container-module__CmFQMq__article {
padding: 0 !important;
font-family: "DM Sans", sans-serif !important;
font-size: 10px !important;
--tweet-header-font-size: 10px !important;
--tweet-header-line-height: 1.25rem !important;
}
.tweet-body-module__ZNRZja__root > * {
font-size: 10px !important;
line-height: 0.25rem !important;
}
[class*="dmSans"],
.font-dm-sans {
letter-spacing: -0.01em;
line-height: 135%;
}
.mcp-client-button-group:hover .mcp-client-gradient-text {
background: linear-gradient(
94deg,
#369bfd 4.8%,
#36fdfd 77.04%,
#36fdb5 143.99%
);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
/* css to fix for ordered list numbers being cut off in chat agent messages */
.chat-markdown-content ol {
padding-left: 1.5rem;
margin-left: 0;
}
.chat-markdown-content ul {
padding-left: 1.5rem;
margin-left: 0;
}
.chat-markdown-content li {
margin-left: 0;
padding-left: 0.25rem;
}
.shadow-inside-out {
box-shadow:
inset 0 2px 4px rgba(0, 0, 0, 0.3),
inset 0 1px 2px rgba(0, 0, 0, 0.1);
}

View file

@ -1,119 +0,0 @@
import { MCPIcon } from "@/components/menu"
import { colors } from "@repo/ui/memory-graph/constants"
import {
GoogleDocs,
MicrosoftWord,
NotionDoc,
GoogleDrive,
GoogleSheets,
GoogleSlides,
PDF,
OneDrive,
MicrosoftOneNote,
MicrosoftPowerpoint,
MicrosoftExcel,
} from "@ui/assets/icons"
import { FileText, Globe } from "lucide-react"
import { useState } from "react"
const getFaviconUrl = (url: string): string => {
try {
const domain = new URL(url).hostname
return `https://www.google.com/s2/favicons?domain=${domain}&sz=32`
} catch {
return ""
}
}
const FaviconIcon = ({
url,
className,
iconProps,
}: {
url: string
className: string
iconProps: { className: string; style: { color: string } }
}) => {
const [hasError, setHasError] = useState(false)
const faviconUrl = getFaviconUrl(url)
if (hasError || !faviconUrl) {
return <Globe {...iconProps} />
}
return (
<img
src={faviconUrl}
alt="Website favicon"
className={className}
style={{
width: "2em",
height: "2em",
objectFit: "contain",
}}
onError={() => setHasError(true)}
/>
)
}
export const getDocumentIcon = (
type: string,
className: string,
source?: string,
url?: string,
) => {
const iconProps = {
className,
style: { color: colors.text.muted },
}
if (source === "mcp") {
return <MCPIcon {...iconProps} />
}
if (
type === "webpage" ||
type === "url" ||
(url && (type === "unknown" || !type))
) {
if (url) {
return (
<FaviconIcon url={url} className={className} iconProps={iconProps} />
)
}
return <Globe {...iconProps} />
}
switch (type) {
case "google_doc":
return <GoogleDocs {...iconProps} />
case "google_sheet":
return <GoogleSheets {...iconProps} />
case "google_slide":
return <GoogleSlides {...iconProps} />
case "google_drive":
return <GoogleDrive {...iconProps} />
case "notion":
case "notion_doc":
return <NotionDoc {...iconProps} />
case "word":
case "microsoft_word":
return <MicrosoftWord {...iconProps} />
case "excel":
case "microsoft_excel":
return <MicrosoftExcel {...iconProps} />
case "powerpoint":
case "microsoft_powerpoint":
return <MicrosoftPowerpoint {...iconProps} />
case "onenote":
case "microsoft_onenote":
return <MicrosoftOneNote {...iconProps} />
case "onedrive":
return <OneDrive {...iconProps} />
case "pdf":
return <PDF {...iconProps} />
default:
return <FileText {...iconProps} />
}
}

View file

@ -18,10 +18,10 @@ export const models = [
export type ModelId = (typeof models)[number]["id"]
export const modelNames: Record<ModelId, string> = {
"gpt-5": "GPT 5",
"claude-sonnet-4.5": "Claude Sonnet 4.5",
"gemini-2.5-pro": "Gemini 2.5 Pro",
export const modelNames: Record<ModelId, { name: string; version: string }> = {
"gpt-5": { name: "GPT", version: "5" },
"claude-sonnet-4.5": { name: "Claude", version: "4.5" },
"gemini-2.5-pro": { name: "Gemini", version: "2.5 Pro" },
}
interface ModelIconProps {

View file

@ -12,12 +12,24 @@ export default async function proxy(request: Request) {
console.debug("[PROXY] Session cookie exists:", !!sessionCookie)
// Always allow access to login and waitlist pages
const publicPaths = ["/login"]
const publicPaths = ["/login", "/login/new"]
if (publicPaths.includes(url.pathname)) {
console.debug("[PROXY] Public path, allowing access")
return NextResponse.next()
}
if (url.pathname.startsWith("/api/")) {
if (!sessionCookie) {
console.debug("[MIDDLEWARE] API route without session, returning 401")
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
})
}
console.debug("[MIDDLEWARE] API route with session, allowing access")
return NextResponse.next()
}
// If no session cookie and not on a public path, redirect to login
if (!sessionCookie) {
console.debug(
@ -54,6 +66,6 @@ export default async function proxy(request: Request) {
export const config = {
matcher: [
"/((?!_next/static|_next/image|images|icon.png|monitoring|opengraph-image.png|ingest|api|login|api/emails).*)",
"/((?!_next/static|_next/image|images|icon.png|monitoring|opengraph-image.png|bg-rectangle.png|onboarding|ingest|login|api/emails).*)",
],
}

View file

@ -44,7 +44,7 @@
"@sentry/nextjs": "^10.22.0",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/react-form": "^1.12.4",
"@tanstack/react-query": "^5.81.2",
"@tanstack/react-query": "^5.90.14",
"@tanstack/react-query-devtools": "^5.84.2",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.12",
@ -58,6 +58,7 @@
"dotenv": "^16.6.0",
"embla-carousel-autoplay": "^8.6.0",
"embla-carousel-react": "^8.6.0",
"flubber": "^0.4.2",
"framer-motion": "^12.23.12",
"idb-keyval": "^6.2.2",
"is-hotkey": "^0.2.0",
@ -67,12 +68,15 @@
"next": "16.0.9",
"next-themes": "^0.4.6",
"nuqs": "^2.5.2",
"pdfjs-dist": "5.4.296",
"posthog-js": "^1.257.0",
"random-word-slugs": "^0.1.7",
"react": "19.2.2",
"react-dom": "19.2.2",
"react-dropzone": "^14.3.8",
"react-hotkeys-hook": "^5.2.1",
"react-markdown": "^10.1.0",
"react-pdf": "^10.2.0",
"react-tweet": "^3.2.2",
"recharts": "2",
"slate": "^0.118.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 MiB

File diff suppressed because it is too large Load diff

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 568 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 717 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Some files were not shown because too many files have changed in this diff Show more