From 100e3ad46f8a1df4f6ee8b637c5484f473fe0fba Mon Sep 17 00:00:00 2001 From: "vorflux[bot]" <249966464+vorflux[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 14:21:27 -0700 Subject: [PATCH 01/32] fix(web): close model selector on outside click (#974) Co-authored-by: MaheshtheDev <38828053+MaheshtheDev@users.noreply.github.com> --- apps/web/components/chat/model-selector.tsx | 94 +++++++++++---------- 1 file changed, 50 insertions(+), 44 deletions(-) diff --git a/apps/web/components/chat/model-selector.tsx b/apps/web/components/chat/model-selector.tsx index 74f3a1ad..a9dd2d45 100644 --- a/apps/web/components/chat/model-selector.tsx +++ b/apps/web/components/chat/model-selector.tsx @@ -1,6 +1,6 @@ "use client" -import { useState } from "react" +import { useEffect, useRef, useState } from "react" import { cn } from "@lib/utils" import { Button } from "@ui/components/button" import { dmSansClassName } from "@/lib/fonts" @@ -23,6 +23,21 @@ export default function ChatModelSelector({ const [internalModel, setInternalModel] = useState("claude-sonnet-4.6") const [isOpen, setIsOpen] = useState(false) + const containerRef = useRef(null) + + useEffect(() => { + if (!isOpen) return + const handleClickOutside = (e: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(e.target as Node) + ) { + setIsOpen(false) + } + } + document.addEventListener("mousedown", handleClickOutside) + return () => document.removeEventListener("mousedown", handleClickOutside) + }, [isOpen]) const selectedModel = selectedModelProp ?? internalModel const currentModelData = modelNames[selectedModel] @@ -73,53 +88,44 @@ export default function ChatModelSelector({ ) return ( -
+
{trigger} {isOpen && ( - <> - - ) - })} -
+
+
+ {models.map((model) => { + const modelData = modelNames[model.id] + return ( + + ) + })}
- +
)}
) From a8e3e92392c6739879a5d129ea20a558aa9db18f Mon Sep 17 00:00:00 2001 From: Mahesh Sanikommu Date: Tue, 19 May 2026 14:35:15 -0700 Subject: [PATCH 02/32] Revert "feat(graph): auto-paginate consumer graph + loading progress chip" (#975) --- apps/web/components/graph-layout-view.tsx | 1 - .../memory-graph/hooks/use-graph-api.ts | 23 +---------- .../memory-graph/memory-graph-wrapper.tsx | 38 +------------------ 3 files changed, 4 insertions(+), 58 deletions(-) diff --git a/apps/web/components/graph-layout-view.tsx b/apps/web/components/graph-layout-view.tsx index 0bbf6a3f..c1fc0311 100644 --- a/apps/web/components/graph-layout-view.tsx +++ b/apps/web/components/graph-layout-view.tsx @@ -46,7 +46,6 @@ export const GraphLayoutView = memo(function GraphLayoutView({ maxNodes={undefined} canvasRef={canvasRef} onOpenDocument={onOpenDocument} - autoFetchAll /> diff --git a/apps/web/components/memory-graph/hooks/use-graph-api.ts b/apps/web/components/memory-graph/hooks/use-graph-api.ts index 41a7d63f..7991f600 100644 --- a/apps/web/components/memory-graph/hooks/use-graph-api.ts +++ b/apps/web/components/memory-graph/hooks/use-graph-api.ts @@ -1,7 +1,7 @@ "use client" import { useInfiniteQuery } from "@tanstack/react-query" -import { useEffect, useMemo } from "react" +import { useMemo } from "react" import { $fetch } from "@lib/api" import type { GraphApiDocument, @@ -15,7 +15,6 @@ interface UseGraphApiOptions { containerTags?: string[] documentIds?: string[] enabled?: boolean - autoFetchAll?: boolean } interface ApiMemoryEntry { @@ -109,12 +108,7 @@ function toGraphDocument( } export function useGraphApi(options: UseGraphApiOptions = {}) { - const { - containerTags, - documentIds, - enabled = true, - autoFetchAll = false, - } = options + const { containerTags, documentIds, enabled = true } = options const filteredDocumentIds = documentIds?.filter(Boolean) const hasDocumentIds = filteredDocumentIds != null && filteredDocumentIds.length > 0 @@ -169,19 +163,6 @@ export function useGraphApi(options: UseGraphApiOptions = {}) { enabled, }) - useEffect(() => { - if (!autoFetchAll || !enabled) return - if (!hasNextPage || isPending || isFetchingNextPage) return - fetchNextPage() - }, [ - autoFetchAll, - enabled, - hasNextPage, - isPending, - isFetchingNextPage, - fetchNextPage, - ]) - const documents = useMemo(() => { if (!data?.pages) return [] return data.pages.flatMap((page) => diff --git a/apps/web/components/memory-graph/memory-graph-wrapper.tsx b/apps/web/components/memory-graph/memory-graph-wrapper.tsx index e2881387..0b2ca826 100644 --- a/apps/web/components/memory-graph/memory-graph-wrapper.tsx +++ b/apps/web/components/memory-graph/memory-graph-wrapper.tsx @@ -1,11 +1,9 @@ "use client" -import { useEffect, useMemo, useRef, useState } from "react" -import { Loader2 } from "lucide-react" +import { useEffect, useRef, useState } from "react" import { MemoryGraph as MemoryGraphBase } from "@supermemory/memory-graph" import type { GraphThemeColors } from "@supermemory/memory-graph" import { useGraphApi } from "./hooks/use-graph-api" -import { cn } from "@lib/utils" export interface MemoryGraphWrapperProps { children?: React.ReactNode @@ -23,7 +21,6 @@ export interface MemoryGraphWrapperProps { onSlideshowStop?: () => void canvasRef?: React.RefObject onOpenDocument?: (documentId: string) => void - autoFetchAll?: boolean } export function MemoryGraph({ @@ -35,7 +32,6 @@ export function MemoryGraph({ documentIds, maxNodes, canvasRef, - autoFetchAll = false, ...rest }: MemoryGraphWrapperProps) { const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }) @@ -64,22 +60,10 @@ export function MemoryGraph({ containerTags, documentIds, enabled: containerSize.width > 0 && containerSize.height > 0, - autoFetchAll, }) - const loadedDocCount = documents.length - const loadedMemoryCount = useMemo( - () => documents.reduce((sum, doc) => sum + doc.memories.length, 0), - [documents], - ) - const showProgress = - autoFetchAll && - !apiIsLoading && - totalCount > 0 && - (hasMore || isLoadingMore || loadedDocCount < totalCount) - return ( -
+
{children} - {showProgress && ( -
- - - {loadedMemoryCount.toLocaleString()} memories ·{" "} - {loadedDocCount.toLocaleString()}/{totalCount.toLocaleString()}{" "} - documents - -
- )}
) } From d7663aa8461812adb4b883704421407db8b1c255 Mon Sep 17 00:00:00 2001 From: Vedant Mahajan Date: Wed, 20 May 2026 03:11:44 +0530 Subject: [PATCH 03/32] Address nova feedback bugs (#970) Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.5 --- apps/web/app/(app)/page.tsx | 11 +- apps/web/components/chat/index.tsx | 184 +++++++++++++++-------------- 2 files changed, 97 insertions(+), 98 deletions(-) diff --git a/apps/web/app/(app)/page.tsx b/apps/web/app/(app)/page.tsx index 9d8e2a64..db50b684 100644 --- a/apps/web/app/(app)/page.tsx +++ b/apps/web/app/(app)/page.tsx @@ -554,20 +554,17 @@ export default function NewPage() { )} > {showNovaBackdrop && ( - <> +
-
+
- +
)} {!session && viewMode === "mcp" ? ( diff --git a/apps/web/components/chat/index.tsx b/apps/web/components/chat/index.tsx index 40e112e3..19103410 100644 --- a/apps/web/components/chat/index.tsx +++ b/apps/web/components/chat/index.tsx @@ -952,100 +952,102 @@ export function ChatSidebar({ {chatToolbarActions}
) : null} -
- {isInputExpanded && ( -
- )} - {messages.length === 0 && ( - - )} +
0 - ? cn( - "flex flex-col space-y-3 min-h-full justify-end", - isPageDesktop ? "pt-2" : "pt-14", - ) - : "" - } - > - {messages.map((message, index) => ( - // biome-ignore lint/a11y/noStaticElementInteractions: Hover detection for message actions -
- message.role === "assistant" && setHoveredMessageId(message.id) - } - onMouseLeave={() => - message.role === "assistant" && setHoveredMessageId(null) - } - > - {message.role === "user" ? ( - - ) : ( - - )} -
- ))} - {(status === "submitted" || status === "streaming") && ( -
- -
+ ref={messagesContainerRef} + className={cn( + "relative h-full overflow-y-auto scrollbar-thin", + "px-4", + dmSansClassName(), )} -
-
- - {!isScrolledToBottom && messages.length > 0 && ( -
- + {messages.map((message, index) => ( + // biome-ignore lint/a11y/noStaticElementInteractions: Hover detection for message actions +
+ message.role === "assistant" && + setHoveredMessageId(message.id) + } + onMouseLeave={() => + message.role === "assistant" && setHoveredMessageId(null) + } + > + {message.role === "user" ? ( + + ) : ( + + )} +
+ ))} + {(status === "submitted" || status === "streaming") && ( +
+ +
+ )} +
- )} + + {!isScrolledToBottom && messages.length > 0 && ( +
+ +
+ )} +
{chatStreamError && (
Date: Tue, 19 May 2026 15:27:20 -0700 Subject: [PATCH 04/32] docs: align vibe-coding prompt + install guide with canonical API (#976) Co-authored-by: Claude Opus 4.7 (1M context) --- apps/docs/install.md | 28 +++++++--- apps/docs/vibe-coding.mdx | 104 ++++++++++++++++++++++++++++---------- 2 files changed, 98 insertions(+), 34 deletions(-) diff --git a/apps/docs/install.md b/apps/docs/install.md index 78830cc5..2968ea08 100644 --- a/apps/docs/install.md +++ b/apps/docs/install.md @@ -46,7 +46,10 @@ export SUPERMEMORY_API_KEY="sm_..." // PATCH https://api.supermemory.ai/v3/settings fetch('https://api.supermemory.ai/v3/settings', { method: 'PATCH', - headers: { 'x-supermemory-api-key': process.env.SUPERMEMORY_API_KEY }, + headers: { + 'Authorization': `Bearer ${process.env.SUPERMEMORY_API_KEY}`, + 'Content-Type': 'application/json', + }, body: JSON.stringify({ shouldLLMFilter: true, filterPrompt: `This is a [your app description]. containerTag is [userId/orgId]. We store [what data].` @@ -210,17 +213,20 @@ client.add(content=f"user: {user_message}\\nassistant: {response}", container_ta ```bash # Add memory curl -X POST https://api.supermemory.ai/v3/documents \ - -H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \ + -H "Authorization: Bearer $SUPERMEMORY_API_KEY" \ + -H "Content-Type: application/json" \ -d '{"content": "conversation", "containerTag": "userId"}' # Get profile curl -X POST https://api.supermemory.ai/v4/profile \ - -H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \ + -H "Authorization: Bearer $SUPERMEMORY_API_KEY" \ + -H "Content-Type: application/json" \ -d '{"containerTag": "userId", "q": "search query"}' # Search curl -X POST https://api.supermemory.ai/v4/search \ - -H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \ + -H "Authorization: Bearer $SUPERMEMORY_API_KEY" \ + -H "Content-Type: application/json" \ -d '{"q": "query", "containerTag": "userId", "searchMode": "hybrid"}' ``` @@ -234,7 +240,10 @@ formData.append('containerTag', userId) await fetch('https://api.supermemory.ai/v3/documents/file', { method: 'POST', - headers: { 'x-supermemory-api-key': process.env.SUPERMEMORY_API_KEY }, + headers: { + 'Authorization': `Bearer ${process.env.SUPERMEMORY_API_KEY}`, + 'Content-Type': 'application/json', + }, body: formData }) @@ -282,17 +291,20 @@ await client.search({ ```bash # 1. Configure settings curl -X PATCH https://api.supermemory.ai/v3/settings \ - -H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \ + -H "Authorization: Bearer $SUPERMEMORY_API_KEY" \ + -H "Content-Type: application/json" \ -d '{"shouldLLMFilter": true, "filterPrompt": "..."}' # 2. Add test memory curl -X POST https://api.supermemory.ai/v3/documents \ - -H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \ + -H "Authorization: Bearer $SUPERMEMORY_API_KEY" \ + -H "Content-Type: application/json" \ -d '{"content": "Test", "containerTag": "test_user"}' # 3. Get profile curl -X POST https://api.supermemory.ai/v4/profile \ - -H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \ + -H "Authorization: Bearer $SUPERMEMORY_API_KEY" \ + -H "Content-Type: application/json" \ -d '{"containerTag": "test_user"}' ``` diff --git a/apps/docs/vibe-coding.mdx b/apps/docs/vibe-coding.mdx index def08fdf..0899692c 100644 --- a/apps/docs/vibe-coding.mdx +++ b/apps/docs/vibe-coding.mdx @@ -52,6 +52,39 @@ You are integrating Supermemory into my application. Supermemory provides user m Note: You can always reference the documentation by using the **SearchSupermemoryDocs MCP** or running a web search tool for content on **supermemory.ai/docs**. +CANONICAL API SURFACE (use these, nothing else): + +- Auth header: `Authorization: Bearer $SUPERMEMORY_API_KEY` — the only supported auth header +- Write content: POST https://api.supermemory.ai/v3/documents +- Search: POST https://api.supermemory.ai/v4/search +- Profile + search: POST https://api.supermemory.ai/v4/profile +- Settings: PATCH https://api.supermemory.ai/v3/settings +- Scoping: `containerTag` (singular string) in the JSON body — never in a header +- SDK: `client.documents.add()`, `client.search.memories()`, `client.profile()` + +DO NOT USE — these are deprecated, undocumented, or fabricated by previous AI codegen: + +- Endpoints: /v1/anything, /v3/memories, /v3/search (use /v3/documents and /v4/search) +- Headers: x-supermemory-api-key, x-api-key, x-sm-user-id, x-sm-project, + x-project-id, X-Workspace-Id (always use Authorization: Bearer) +- Body keys: containerTags (plural array), userId, spaces, schema, container, + tags (top-level), filter (singular) (use containerTag + filters) +- SDK calls: client.search.execute, client.documents.add (use client.add), + client.documents.deleteBulk, client.documents.batch_add, + client.memories.updateMemory (the real method is client.memories.update) +- Kwargs: chunk_threshold (use `threshold`), sort, order, include_content, + include_full_docs, timeout (as an SDK kwarg) + +NOTE on memory mutation: `client.memories.update`, `client.memories.delete`, and +`client.memories.forget` ARE real and supported — but most apps don't need them. +Memories are auto-extracted from documents. Only reach for these if you're exposing +a "manage my memories" UI to end users or agents. +- Mixing: `rerank` and `rewriteQuery` are valid on /v4/search ONLY — never on /v3/search + +SCOPING IS LOAD-BEARING. Every write and every search MUST include `containerTag`. +If you omit it, every user's data collapses into the API key's default bucket — this +is the single most common bug in AI-generated Supermemory integrations. + STEP 1: ASK ME THESE QUESTIONS 1. What are you building? @@ -91,29 +124,34 @@ export SUPERMEMORY_API_KEY="sm_..." STEP 3: CONFIGURE SETTINGS (DO THIS FIRST) -typescript +```typescript // PATCH https://api.supermemory.ai/v3/settings fetch('https://api.supermemory.ai/v3/settings', { method: 'PATCH', - headers: { 'x-supermemory-api-key': process.env.SUPERMEMORY_API_KEY }, + headers: { + 'Authorization': `Bearer ${process.env.SUPERMEMORY_API_KEY}`, + 'Content-Type': 'application/json', + }, body: JSON.stringify({ shouldLLMFilter: true, filterPrompt: `This is a [your app description]. containerTag is [userId/orgId]. We store [what data].` }) }) +``` STEP 4: CONTAINER TAG STRATEGY Based on their data model answer: USER-ONLY APP: -typescript - +```typescript +containerTag: userId // Each user's memories are isolated +``` ORG-ONLY APP: -typescript +```typescript containerTag: orgId // Org members share memories - +``` BOTH (ask which): - Option A: `containerTag: \`\${userId}-\${orgId}\`` @@ -126,7 +164,7 @@ Based on their integration choice: --- VERCEL AI SDK --- -typescript +```typescript import { streamText } from 'ai' import { anthropic } from '@ai-sdk/anthropic' import { supermemoryTools } from '@supermemory/tools/ai-sdk' @@ -136,7 +174,7 @@ const result = await streamText({ model: anthropic('claude-3-5-sonnet-20241022'), prompt: userMessage, tools: supermemoryTools(process.env.SUPERMEMORY_API_KEY, { - containerTags: [userId] + containerTag: userId // singular string — never an array }) }) // Agent gets searchMemories, addMemory, fetchMemory tools @@ -153,11 +191,12 @@ const result = await generateText({ messages: [{ role: 'user', content: userMessage }] }) // Profile is automatically injected into context +``` --- DIRECT SDK (WITH PROFILES) --- -typescript +```typescript import Supermemory from 'supermemory' const client = new Supermemory() @@ -187,7 +226,7 @@ await client.add({ content: `user: ${userMessage}\nassistant: ${response}`, containerTag: userId }) - +``` --- DIRECT SDK (NO PROFILES) --- @@ -218,9 +257,11 @@ await client.add({ content: `user: ${userMessage}\nassistant: ${response}`, containerTag: userId }) +``` --- PYTHON VERSION --- -python + +```python from supermemory import Supermemory client = Supermemory() @@ -238,28 +279,33 @@ Dynamic: {chr(10).join(profile_data.profile.dynamic)} # Store conversation client.add(content=f"user: {user_message}\\nassistant: {response}", container_tag=user_id) +``` --- DIRECT API --- -bash +```bash # Add memory curl -X POST https://api.supermemory.ai/v3/documents \ - -H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \ + -H "Authorization: Bearer $SUPERMEMORY_API_KEY" \ + -H "Content-Type: application/json" \ -d '{"content": "conversation", "containerTag": "userId"}' # Get profile curl -X POST https://api.supermemory.ai/v4/profile \ - -H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \ + -H "Authorization: Bearer $SUPERMEMORY_API_KEY" \ + -H "Content-Type: application/json" \ -d '{"containerTag": "userId", "q": "search query"}' # Search curl -X POST https://api.supermemory.ai/v4/search \ - -H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \ + -H "Authorization: Bearer $SUPERMEMORY_API_KEY" \ + -H "Content-Type: application/json" \ -d '{"q": "query", "containerTag": "userId", "searchMode": "hybrid"}' +``` STEP 6: FILE UPLOADS (if they need it) -typescript +```typescript // Files are automatically extracted (PDFs, images with OCR, videos with transcription) const formData = new FormData() formData.append('file', fileBlob) @@ -267,28 +313,29 @@ formData.append('containerTag', userId) await fetch('https://api.supermemory.ai/v3/documents/file', { method: 'POST', - headers: { 'x-supermemory-api-key': process.env.SUPERMEMORY_API_KEY }, + headers: { 'Authorization': `Bearer ${process.env.SUPERMEMORY_API_KEY}` }, body: formData }) // Processing is async - check status before assuming searchable // GET /v3/documents/{documentId} - +``` STEP 7: SEARCH MODES -typescript +```typescript // HYBRID (recommended) - searches memories + document chunks searchMode: 'hybrid' // MEMORIES ONLY - just extracted memories, no original text searchMode: 'memories' - +``` STEP 8: METADATA FILTERS (if they need secondary filtering) -typescript -await client.search({ +```typescript +// Always against /v4/search — rerank/rewriteQuery/filters are v4-only +await client.search.memories({ q: query, containerTag: userId, filters: { @@ -298,6 +345,7 @@ await client.search({ ] } }) +``` KEY POINTS: @@ -311,21 +359,25 @@ KEY POINTS: TESTING: -bash +```bash # 1. Configure settings curl -X PATCH https://api.supermemory.ai/v3/settings \ - -H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \ + -H "Authorization: Bearer $SUPERMEMORY_API_KEY" \ + -H "Content-Type: application/json" \ -d '{"shouldLLMFilter": true, "filterPrompt": "..."}' # 2. Add test memory curl -X POST https://api.supermemory.ai/v3/documents \ - -H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \ + -H "Authorization: Bearer $SUPERMEMORY_API_KEY" \ + -H "Content-Type: application/json" \ -d '{"content": "Test", "containerTag": "test_user"}' # 3. Get profile curl -X POST https://api.supermemory.ai/v4/profile \ - -H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \ + -H "Authorization: Bearer $SUPERMEMORY_API_KEY" \ + -H "Content-Type: application/json" \ -d '{"containerTag": "test_user"}' +``` NOW: From 68f043b14d9748042fb302bb76acb54b54896645 Mon Sep 17 00:00:00 2001 From: MaheshtheDev <38828053+MaheshtheDev@users.noreply.github.com> Date: Wed, 20 May 2026 03:36:31 +0000 Subject: [PATCH 05/32] fix(web): route Drive-hosted PDF URLs through backend proxy (#977) Drive-ingested PDFs persist a raw googleapis.com URL that the browser can't fetch directly (403). Detect googleapis.com hostnames in PdfViewer and rewrite to `/v3/drive-proxy/` with credentials. R2-hosted PDFs are unaffected. --- .../document-modal/content/index.tsx | 2 +- .../components/document-modal/content/pdf.tsx | 22 ++++++++++++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/apps/web/components/document-modal/content/index.tsx b/apps/web/components/document-modal/content/index.tsx index b3df9fe7..81f29b29 100644 --- a/apps/web/components/document-modal/content/index.tsx +++ b/apps/web/components/document-modal/content/index.tsx @@ -103,7 +103,7 @@ export function DocumentContent({ return case "pdf": - return + return case "notion": return diff --git a/apps/web/components/document-modal/content/pdf.tsx b/apps/web/components/document-modal/content/pdf.tsx index d096b767..e633db86 100644 --- a/apps/web/components/document-modal/content/pdf.tsx +++ b/apps/web/components/document-modal/content/pdf.tsx @@ -1,7 +1,7 @@ "use client" import { Document, Page, pdfjs } from "react-pdf" -import { useCallback, useState } from "react" +import { useCallback, useMemo, useState } from "react" import "react-pdf/dist/Page/AnnotationLayer.css" import "react-pdf/dist/Page/TextLayer.css" @@ -13,9 +13,25 @@ pdfjs.GlobalWorkerOptions.workerSrc = new URL( interface PdfViewerProps { url: string | null | undefined + documentId?: string | null } -export function PdfViewer({ url }: PdfViewerProps) { +export function PdfViewer({ url, documentId }: PdfViewerProps) { + const fileSource = useMemo(() => { + if (!url) return null + try { + if (new URL(url).hostname === "www.googleapis.com" && documentId) { + const base = + process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai" + return { + url: `${base}/v3/drive-proxy/${documentId}`, + withCredentials: true, + } + } + } catch {} + return url + }, [url, documentId]) + const [numPages, setNumPages] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -70,7 +86,7 @@ export function PdfViewer({ url }: PdfViewerProps) { Date: Wed, 20 May 2026 03:37:49 +0000 Subject: [PATCH 06/32] fix: dashboard Notion/Google Drive suggestions open connect modal (#978) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The home dashboard suggestion cards routed Notion/Google Drive clicks through setViewMode("notion"|"google-drive"), but those values aren't in viewLiterals, so nuqs rejected them and silently fell back to the dashboard view — the click did nothing. Route those two providers to the connect modal (setAddDoc("connect")), matching the Integrations 'Connections' card, and widen IntegrationParamValue to include them. --- apps/web/app/(app)/page.tsx | 6 +++++- apps/web/lib/search-params.ts | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/web/app/(app)/page.tsx b/apps/web/app/(app)/page.tsx index db50b684..fd2fd171 100644 --- a/apps/web/app/(app)/page.tsx +++ b/apps/web/app/(app)/page.tsx @@ -514,9 +514,13 @@ export default function NewPage() { const handleOpenIntegrations = useCallback( (integration?: IntegrationParamValue) => { + if (integration === "notion" || integration === "google-drive") { + void setAddDoc("connect") + return + } void setViewMode(integration ?? "integrations") }, - [setViewMode], + [setViewMode, setAddDoc], ) const handleOpenPlugins = useCallback(() => { diff --git a/apps/web/lib/search-params.ts b/apps/web/lib/search-params.ts index b283baf5..3978bc37 100644 --- a/apps/web/lib/search-params.ts +++ b/apps/web/lib/search-params.ts @@ -42,7 +42,8 @@ export type ViewParamValue = (typeof viewLiterals)[number] export const viewParam = parseAsStringLiteral(viewLiterals).withDefault("dashboard") -// Kept for backwards compat with components that pass integration hints +// Kept for backwards compat with components that pass integration hints. +// "notion"/"google-drive" are connection providers, not view modes — they open the connect modal. export type IntegrationParamValue = | "mcp" | "plugins" @@ -51,6 +52,8 @@ export type IntegrationParamValue = | "shortcuts" | "raycast" | "import" + | "notion" + | "google-drive" export const categoriesParam = parseAsArrayOf(parseAsString, ",").withDefault( [], ) From 41a90016601036203023f505ebf6ec7061eb5aee Mon Sep 17 00:00:00 2001 From: Mahesh Sanikommu Date: Wed, 20 May 2026 02:26:10 -0700 Subject: [PATCH 07/32] feat(web): redesign settings layout + split billing into its own tab (#980) --- apps/web/app/(app)/settings/page.tsx | 618 ++++++---- apps/web/components/settings/account.tsx | 1037 +++-------------- apps/web/components/settings/billing.tsx | 579 +++++++++ .../components/settings/connections-mcp.tsx | 2 +- apps/web/components/settings/integrations.tsx | 2 +- apps/web/components/settings/support.tsx | 2 +- 6 files changed, 1094 insertions(+), 1146 deletions(-) create mode 100644 apps/web/components/settings/billing.tsx diff --git a/apps/web/app/(app)/settings/page.tsx b/apps/web/app/(app)/settings/page.tsx index ddf8a524..f5d8c333 100644 --- a/apps/web/app/(app)/settings/page.tsx +++ b/apps/web/app/(app)/settings/page.tsx @@ -2,12 +2,12 @@ import { Logo } from "@ui/assets/Logo" import { UserProfileMenu } from "@/components/user-profile-menu" 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 { useState, useEffect, useRef, useMemo } from "react" import { cn } from "@lib/utils" import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts" import Account from "@/components/settings/account" +import Billing from "@/components/settings/billing" import Integrations from "@/components/settings/integrations" import ConnectionsMCP from "@/components/settings/connections-mcp" import Support from "@/components/settings/support" @@ -16,13 +16,42 @@ import { useRouter } from "next/navigation" import { useIsMobile } from "@hooks/use-mobile" import { useLocalStorageUsername } from "@hooks/use-local-storage-username" import { analytics } from "@/lib/analytics" -import { LogOut, RotateCcw, Trash2, Sun, LoaderIcon } from "lucide-react" +import { + LogOut, + RotateCcw, + Trash2, + Sun, + LoaderIcon, + User as UserIcon, + Zap, + HelpCircle, + CreditCard, + ShieldAlert, + ChevronRight, + ChevronsUpDown, + Check, + Building2, +} from "lucide-react" import { authClient } from "@lib/auth" import { Dialog, DialogContent, DialogClose } from "@ui/components/dialog" +import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover" import { useResetOrganization } from "@/hooks/use-reset-organization" import { useDeleteUserAccount } from "@/hooks/use-account-settings" +import { useCustomer } from "autumn-js/react" +import { useOrgSummaries } from "@/hooks/use-org-summaries" +import { + PLAN_DISPLAY_NAMES, + useTokenUsage, + type PlanType, +} from "@/hooks/use-token-usage" -const TABS = ["account", "integrations", "connections", "support"] as const +const TABS = [ + "account", + "billing", + "integrations", + "connections", + "support", +] as const type SettingsTab = (typeof TABS)[number] type NavItem = { @@ -32,134 +61,39 @@ type NavItem = { icon: React.ReactNode } -type DangerItem = { - id: "logout" | "reset" | "delete" - label: string - description: string - icon: React.ReactNode - color: "neutral" | "amber" | "red" -} - const NAV_ITEMS: NavItem[] = [ { id: "account", - label: "Account & Billing", - description: "Manage your profile, plan, usage and payments", - icon: ( - - ), + label: "Account", + description: "Your profile and organization", + icon: , + }, + { + id: "billing", + label: "Billing", + description: "Plan, usage and payments", + icon: , }, { id: "integrations", label: "Integrations", - description: "Save, sync and search memories across tools", - icon: , + description: "Save, sync and search across tools", + icon: , }, { id: "connections", label: "Connections & MCP", - description: "Sync with Google Drive, Notion, OneDrive and MCP client", - icon: ( - - ), + description: "Drive, Notion, OneDrive, MCP", + icon: , }, { id: "support", label: "Support & Help", - description: "Find answers or share feedback. We're here to help.", - icon: ( - - ), + description: "Get help or share feedback", + icon: , }, ] -const DANGER_ITEMS: DangerItem[] = [ - { - id: "logout", - label: "Log out", - description: "Sign out of your account on this device", - icon: , - color: "neutral", - }, - { - id: "reset", - label: "Reset data", - description: "Erase all memories, connections and spaces", - icon: , - color: "amber", - }, - { - id: "delete", - label: "Delete account", - description: "Permanently delete your account and all data", - icon: , - color: "red", - }, -] - -const DANGER_COLORS: Record< - DangerItem["color"], - { idle: string; hover: string; icon: string } -> = { - neutral: { - idle: "text-white/50", - hover: "hover:text-white", - icon: "text-white/40", - }, - amber: { - idle: "text-[#7A6030]", - hover: "hover:text-[#C7991B]", - icon: "text-[#7A6030]", - }, - red: { - idle: "text-[#6B2A2A]", - hover: "hover:text-[#C73B1B]", - icon: "text-[#6B2A2A]", - }, -} - function parseHashToTab(hash: string): SettingsTab { const cleaned = hash.replace("#", "").toLowerCase() return TABS.includes(cleaned as SettingsTab) @@ -167,30 +101,75 @@ function parseHashToTab(hash: string): SettingsTab { : "account" } -export function UserSupermemory({ name }: { name: string }) { +const ORG_PLAN_BADGE_STYLES: Record = { + free: "bg-[#2E353D] font-mono font-medium tracking-[0.12em] text-[#A3A3A3]", + pro: "bg-[#4BA0FA] font-bold tracking-[0.36px] text-[#00171A]", + scale: "bg-[#0054AD] font-bold tracking-[0.36px] text-[#FAFAFA]", + enterprise: "bg-[#FAFAFA] font-bold tracking-[0.36px] text-[#0D121A]", +} + +function OrgPlanBadge({ plan }: { plan: PlanType }) { return ( - - -
-

- {name.split(" ")[0]}'s -

-

- supermemory -

+ {PLAN_DISPLAY_NAMES[plan]} + + ) +} + +function resolveOrgPlan( + orgId: string, + isCurrent: boolean, + currentPlan: PlanType, + planByOrgId: Map, +): PlanType { + const fromSummary = planByOrgId.get(orgId) + if (fromSummary) return fromSummary + if (isCurrent) return currentPlan + return "free" +} + +function SectionLabel({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ) +} + +function IdentityCard({ displayName }: { displayName: string }) { + const firstName = displayName?.split(" ")[0] || "" + + return ( +
+ +
+ +
+

+ {firstName ? `${firstName}'s` : "Your"} +

+

+ supermemory +

+
- +
) } export default function SettingsPage() { - const { user, org } = useAuth() + const { user, org, organizations, setActiveOrg } = useAuth() const [activeTab, setActiveTab] = useState("account") const hasInitialized = useRef(false) const router = useRouter() @@ -205,6 +184,40 @@ export default function SettingsPage() { const [deleteEmailConfirm, setDeleteEmailConfirm] = useState("") const deleteUserAccount = useDeleteUserAccount() + const [dangerMenuOpen, setDangerMenuOpen] = useState(false) + const [orgSwitcherOpen, setOrgSwitcherOpen] = useState(false) + const [switchingOrgId, setSwitchingOrgId] = useState(null) + const canSwitchOrg = (organizations?.length ?? 0) > 1 + + const autumn = useCustomer() + const { currentPlan } = useTokenUsage(autumn) + const { data: orgSummaries } = useOrgSummaries() + const planByOrgId = useMemo(() => { + const map = new Map() + for (const summary of orgSummaries ?? []) { + map.set(summary.orgId, summary.plan) + } + return map + }, [orgSummaries]) + const activeOrgPlan = org?.id + ? resolveOrgPlan(org.id, true, currentPlan, planByOrgId) + : currentPlan + + const handleOrgSwitch = async (orgSlug: string, orgId: string) => { + if (orgId === org?.id) { + setOrgSwitcherOpen(false) + return + } + setSwitchingOrgId(orgId) + try { + await setActiveOrg(orgSlug) + window.location.reload() + } catch (error) { + console.error("Failed to switch organization:", error) + setSwitchingOrgId(null) + } + } + const handleLogout = async () => { await authClient.signOut() router.push("/login") @@ -233,7 +246,6 @@ export default function SettingsPage() { setActiveTab(tab) analytics.settingsTabChanged({ tab }) - // If no hash or invalid hash, push #account if (!hash || !TABS.includes(hash.replace("#", "") as SettingsTab)) { window.history.pushState(null, "", "#account") } @@ -256,13 +268,10 @@ export default function SettingsPage() { user?.name || user?.email?.split("@")[0] || "" - const headerPossessive = headerDisplayName - ? `${headerDisplayName.split(" ")[0]}'s` - : "Your" return ( -
-
+
+
- -
-
-
-
- {!isMobile && ( - + {!isMobile && ( + { + if (canSwitchOrg) setOrgSwitcherOpen(open) + }} + > + + + + - - - + {[...(organizations ?? [])] + .sort((a, b) => a.name.localeCompare(b.name)) + .map((organization) => { + const isCurrent = organization.id === org?.id + const isSwitching = switchingOrgId === organization.id + const plan = resolveOrgPlan( + organization.id, + isCurrent, + currentPlan, + planByOrgId, + ) + return ( + + ) + })} + + + )} + +
+
+ +
+
+ {/* Left rail */} +
-
+ + + {/* Content */} +
{activeTab === "account" && } + {activeTab === "billing" && } {activeTab === "integrations" && } {activeTab === "connections" && } {activeTab === "support" && } -
+
diff --git a/apps/web/components/settings/account.tsx b/apps/web/components/settings/account.tsx index 92fa8093..57d1264b 100644 --- a/apps/web/components/settings/account.tsx +++ b/apps/web/components/settings/account.tsx @@ -3,10 +3,6 @@ import { dmSans125ClassName } from "@/lib/fonts" import { cn } from "@lib/utils" import { useAuth } from "@lib/auth-context" -import { - useAccountMemberships, - useDeleteUserAccount, -} from "@/hooks/use-account-settings" import { useOrgSummaries } from "@/hooks/use-org-summaries" import { Avatar, AvatarFallback, AvatarImage } from "@ui/components/avatar" import { @@ -15,26 +11,10 @@ import { useTokenUsage, type PlanType, } from "@/hooks/use-token-usage" -import { - Dialog, - DialogContent, - DialogTrigger, - DialogClose, -} from "@ui/components/dialog" -import { authClient } from "@lib/auth" import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover" import { useCustomer } from "autumn-js/react" -import { - Check, - X, - Trash2, - LoaderIcon, - Settings, - ChevronDown, - Building2, -} from "lucide-react" +import { Check, LoaderIcon, ChevronDown, Building2, Users } from "lucide-react" import { useMemo, useState } from "react" -import { toast } from "sonner" function SectionTitle({ children }: { children: React.ReactNode }) { return ( @@ -62,137 +42,6 @@ function SettingsCard({ children }: { children: React.ReactNode }) { ) } -function PlanComparisonCard({ - name, - price, - period, - description, - credits, - features, - highlight, -}: { - name: string - price: string - period: string - description: string - credits: string - features: string[] - highlight: boolean -}) { - return ( -
-
-

- {name} -

- {highlight && ( - - RECOMMENDED - - )} -
- -
- - {price} - - {period && ( - - {period} - - )} -
- -

- {description} -

- -
-
-

- {credits} -

-

- of usage included -

-
-
- -
    - {features.map((text) => ( -
  • - - {text} -
  • - ))} -
-
- ) -} - -function formatOrgRole(role: string): string { - const r = role.toLowerCase() - if (r === "owner") return "Owner" - if (r === "admin") return "Admin" - if (r === "member") return "Member" - return role - ? role.charAt(0).toUpperCase() + role.slice(1).toLowerCase() - : "Member" -} - /** Matches ACTIVE / RECOMMENDED pills in billing settings. */ const orgPlanBadgeBase = cn( dmSans125ClassName(), @@ -214,6 +63,36 @@ function OrgPlanBadge({ plan }: { plan: PlanType }) { ) } +const ROLE_LABELS: Record = { + owner: "Owner", + admin: "Admin", + member: "Member", +} + +function formatRole(role: string): string { + const r = role?.toLowerCase() ?? "" + if (ROLE_LABELS[r]) return ROLE_LABELS[r] + return r ? r.charAt(0).toUpperCase() + r.slice(1) : "Member" +} + +function RolePill({ role }: { role: string }) { + const r = role?.toLowerCase() ?? "" + const isOwner = r === "owner" + return ( + + {formatRole(role)} + + ) +} + function resolveOrgPlan( orgId: string, isCurrent: boolean, @@ -227,52 +106,13 @@ function resolveOrgPlan( } export default function Account() { - const { - user, - org, - organizations: allOrgs, - setActiveOrg, - clearActiveOrg, - } = useAuth() + const { user, org, organizations: allOrgs, setActiveOrg } = useAuth() const autumn = useCustomer() - const [isUpgrading, setIsUpgrading] = useState(false) - const [isCancelling, setIsCancelling] = useState(false) - const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false) - const [emailConfirm, setEmailConfirm] = useState("") - const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) - const [isClosingAccount, setIsClosingAccount] = useState(false) const [switchingOrgId, setSwitchingOrgId] = useState(null) const [orgMenuOpen, setOrgMenuOpen] = useState(false) const canSwitchOrg = (allOrgs?.length ?? 0) > 1 - const { data: memberships, isPending: membershipsPending } = - useAccountMemberships() const { data: orgSummaries } = useOrgSummaries() - const sortedMemberships = useMemo(() => { - if (!memberships?.length) return [] - return [...memberships].sort((a, b) => a.name.localeCompare(b.name)) - }, [memberships]) - - const ownedOrgs = useMemo( - () => memberships?.filter((m) => m.role === "owner") ?? [], - [memberships], - ) - - const hasOwnedOrgWithTeammates = useMemo( - () => ownedOrgs.some((m) => m.memberCount > 1), - [ownedOrgs], - ) - - const showMembershipsOverview = - !membershipsPending && - (sortedMemberships.length > 1 || hasOwnedOrgWithTeammates) - - const deleteUserAccount = useDeleteUserAccount() - - const emailMatches = user?.email - ? emailConfirm.trim().toLowerCase() === user.email.trim().toLowerCase() - : false - const handleOrgSwitch = async (orgSlug: string, orgId: string) => { if (orgId === org?.id) return setSwitchingOrgId(orgId) @@ -285,23 +125,7 @@ export default function Account() { } } - const { - usdIncluded, - usdSpent, - planUsagePct, - currentPlan, - hasPaidPlan, - isLoading: isCheckingStatus, - daysRemaining, - } = useTokenUsage(autumn) - - const formatUsd = (n: number) => - n.toLocaleString(undefined, { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }) - - const planDisplayNames = PLAN_DISPLAY_NAMES + const { currentPlan } = useTokenUsage(autumn) const planByOrgId = useMemo(() => { const map = new Map() @@ -332,80 +156,6 @@ export default function Account() { }) }, [allOrgs, org?.id, currentPlan, planByOrgId]) - // Handlers - const handleUpgrade = async () => { - setIsUpgrading(true) - try { - const result = await autumn.attach({ - planId: "api_pro", - successUrl: `${window.location.origin}/settings#account`, - }) - if (result?.paymentUrl) { - window.open(result.paymentUrl, "_self") - return - } - autumn.refetch?.() - } catch (error) { - console.error(error) - toast.error("Failed to start checkout. Please try again.") - } finally { - setIsUpgrading(false) - } - } - - // Enterprise is contract-based — direct those users to the portal/sales. - const cancellablePlanId = - currentPlan === "pro" || currentPlan === "scale" - ? (`api_${currentPlan}` as const) - : null - - const handleCancelSubscription = async () => { - if (!cancellablePlanId) return - setIsCancelling(true) - try { - await autumn.updateSubscription({ - planId: cancellablePlanId, - cancelAction: "cancel_end_of_cycle", - }) - autumn.refetch?.() - setIsCancelDialogOpen(false) - toast.success( - `Subscription cancelled. ${planDisplayNames[currentPlan]} features remain active until the end of your billing period.`, - ) - } catch (error) { - console.error(error) - toast.error("Failed to cancel subscription. Please try again.") - } finally { - setIsCancelling(false) - } - } - - const handleDeleteAccount = async () => { - if (!user?.email || !emailMatches || membershipsPending) return - setIsClosingAccount(true) - try { - await deleteUserAccount.mutateAsync({ - confirmation: user.email, - }) - clearActiveOrg() - try { - await authClient.signOut() - } catch { - window.location.assign("/login/new") - return - } - setIsDeleteDialogOpen(false) - setEmailConfirm("") - window.location.assign("/login/new") - } catch (e) { - const msg = e instanceof Error ? e.message : "Something went wrong" - toast.error(msg) - } finally { - setIsClosingAccount(false) - } - } - - // Format member since date const memberSince = user?.createdAt ? new Date(user.createdAt).toLocaleDateString("en-US", { month: "short", @@ -414,7 +164,7 @@ export default function Account() { : "—" return ( -
+
Profile Details @@ -567,628 +317,117 @@ export default function Account() {
-
- Billing & Subscription - -
- {hasPaidPlan ? ( - <> -
-
-

- {planDisplayNames[currentPlan]} plan -

- - ACTIVE - -
-

- Expanded memory with connections and more -

-
- - {/* Plan usage (unified) */} -
-
-

- Plan usage -

- - {planUsagePct < 1 && planUsagePct > 0 - ? "< 1" - : Math.round(planUsagePct)} - % used - -
-
-
80 - ? "#ef4444" - : "linear-gradient(to right, #4BA0FA 80%, #002757 100%)", - }} - title={`$${formatUsd(usdSpent)} of $${formatUsd(usdIncluded)} used`} - /> -
-

- {daysRemaining !== null - ? `Resets in ${daysRemaining} day${daysRemaining !== 1 ? "s" : ""}` - : ""} -

-
- -
- - {cancellablePlanId && ( - - - - - -
-
-
-

- Cancel {planDisplayNames[currentPlan]}{" "} - subscription? -

-

- You'll keep Pro features until the end of - your current billing period - {daysRemaining !== null - ? ` (${daysRemaining} day${daysRemaining !== 1 ? "s" : ""} remaining)` - : ""} - . After that, your account will switch to the - Free plan. -

-
- - - -
- -
- - - - -
-
-
- -
- )} -
- - ) : ( - <> -
-

- Free Plan -

-

- You are on basic plan -

-
- - {/* Plan usage (unified) */} -
-
-

- Plan usage -

-

- {planUsagePct < 1 && planUsagePct > 0 - ? "< 1" - : Math.round(planUsagePct)} - % used -

-
-
-
80 ? "#ef4444" : "#0054AD", - }} - title={`$${formatUsd(usdSpent)} of $${formatUsd(usdIncluded)} used`} - /> -
-

- {daysRemaining !== null - ? `Resets in ${daysRemaining} day${daysRemaining !== 1 ? "s" : ""}` - : ""} -

-
- - - -
- - -
- - )} -
- -
- -
- Delete Account - -
-

+

+ Team members + {(org?.members?.length ?? 0) > 0 && ( + - Permanently delete all your data and cancel any active - subscriptions -

- { - setIsDeleteDialogOpen(open) - if (!open) { - setEmailConfirm("") - } - }} - > - - - - -
- {/* Header */} -
-
-
-

- Delete account? -

-

- This cannot be undone. -

- {hasOwnedOrgWithTeammates && ( -

- You own at least one organization that still has - other members. Those organizations will be deleted - for everyone when you confirm. -

- )} -
- - What happens next? - - -
-

- Your account is locked immediately; data removal - runs in the background. -

-
    -
  • - Removes memories, conversations, and settings; - cancels active subscriptions. -
  • -
  • - Orgs where you're only a member: - you're removed; the org continues. -
  • -
  • Orgs you own: deleted for all members.
  • -
-
-
-
- - - -
- - {showMembershipsOverview && ( -
-

- Your organizations -

-
- {sortedMemberships.map((m) => ( -
-
-

- {m.name} -

- {m.slug ? ( -

- {m.slug} -

- ) : null} -
-
- - {formatOrgRole(m.role)} - - - {m.memberCount} member - {m.memberCount === 1 ? "" : "s"} - -
-
- ))} -
-
- )} - - {/* Confirmation input */} -
-

- Type your account email to confirm: -

-
- setEmailConfirm(e.target.value)} - placeholder={user?.email ?? "you@example.com"} - className={cn( - "w-full px-4 py-3 bg-transparent", - "text-[#FAFAFA] placeholder:text-[#737373]", - "text-[14px] tracking-[-0.14px]", - "outline-none", - dmSans125ClassName(), - )} - /> -
-
-
-
- - {/* Footer */} -
- - - -
+ + {org?.members && org.members.length > 0 ? ( +
    + {[...org.members] + .sort((a, b) => { + const rolePriority = (r: string) => + r === "owner" ? 0 : r === "admin" ? 1 : 2 + const diff = + rolePriority(a.role.toLowerCase()) - + rolePriority(b.role.toLowerCase()) + if (diff !== 0) return diff + return (a.user?.name ?? "").localeCompare(b.user?.name ?? "") + }) + .map((m, idx) => { + const isYou = m.userId === user?.id + const name = m.user?.name ?? m.user?.email ?? "Unknown" + return ( +
  • 0 && "border-t border-white/[0.04]", )} > - {isClosingAccount ? ( - - ) : ( - - )} - {isClosingAccount ? "Deleting…" : "Delete"} -
    - -
    -
- {/* Modal inset highlight */} -
- -
-
+ + + + {(name.charAt(0) || "U").toUpperCase()} + + +
+
+ + {name} + + {isYou && ( + + You + + )} +
+ {m.user?.email && ( + + {m.user.email} + + )} +
+ + + ) + })} + + ) : ( +
+
+ +
+
+ + Just you for now + + + Invite teammates from your organization settings. + +
+
+ )}
diff --git a/apps/web/components/settings/billing.tsx b/apps/web/components/settings/billing.tsx new file mode 100644 index 00000000..aca23307 --- /dev/null +++ b/apps/web/components/settings/billing.tsx @@ -0,0 +1,579 @@ +"use client" + +import { dmSans125ClassName } from "@/lib/fonts" +import { cn } from "@lib/utils" +import { PLAN_DISPLAY_NAMES, useTokenUsage } from "@/hooks/use-token-usage" +import { + Dialog, + DialogContent, + DialogTrigger, + DialogClose, +} from "@ui/components/dialog" +import { useCustomer } from "autumn-js/react" +import { Check, X, LoaderIcon, Settings } from "lucide-react" +import { useState } from "react" +import { toast } from "sonner" + +function SectionTitle({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ) +} + +function SettingsCard({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ) +} + +function PlanComparisonCard({ + name, + price, + period, + description, + credits, + features, + highlight, +}: { + name: string + price: string + period: string + description: string + credits: string + features: string[] + highlight: boolean +}) { + return ( +
+
+

+ {name} +

+ {highlight && ( + + RECOMMENDED + + )} +
+ +
+ + {price} + + {period && ( + + {period} + + )} +
+ +

+ {description} +

+ +
+
+

+ {credits} +

+

+ of usage included +

+
+
+ +
    + {features.map((text) => ( +
  • + + {text} +
  • + ))} +
+
+ ) +} + +export default function Billing() { + const autumn = useCustomer() + const [isUpgrading, setIsUpgrading] = useState(false) + const [isCancelling, setIsCancelling] = useState(false) + const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false) + + const { + usdIncluded, + usdSpent, + planUsagePct, + currentPlan, + hasPaidPlan, + isLoading: isCheckingStatus, + daysRemaining, + } = useTokenUsage(autumn) + + const formatUsd = (n: number) => + n.toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }) + + const planDisplayNames = PLAN_DISPLAY_NAMES + + const handleUpgrade = async () => { + setIsUpgrading(true) + try { + const result = await autumn.attach({ + planId: "api_pro", + successUrl: `${window.location.origin}/settings#billing`, + }) + if (result?.paymentUrl) { + window.open(result.paymentUrl, "_self") + return + } + autumn.refetch?.() + } catch (error) { + console.error(error) + toast.error("Failed to start checkout. Please try again.") + } finally { + setIsUpgrading(false) + } + } + + const cancellablePlanId = + currentPlan === "pro" || currentPlan === "scale" + ? (`api_${currentPlan}` as const) + : null + + const handleCancelSubscription = async () => { + if (!cancellablePlanId) return + setIsCancelling(true) + try { + await autumn.updateSubscription({ + planId: cancellablePlanId, + cancelAction: "cancel_end_of_cycle", + }) + autumn.refetch?.() + setIsCancelDialogOpen(false) + toast.success( + `Subscription cancelled. ${planDisplayNames[currentPlan]} features remain active until the end of your billing period.`, + ) + } catch (error) { + console.error(error) + toast.error("Failed to cancel subscription. Please try again.") + } finally { + setIsCancelling(false) + } + } + + return ( +
+
+ Billing & Subscription + +
+ {hasPaidPlan ? ( + <> +
+
+

+ {planDisplayNames[currentPlan]} plan +

+ + ACTIVE + +
+

+ Expanded memory with connections and more +

+
+ +
+
+

+ Plan usage +

+ + {planUsagePct < 1 && planUsagePct > 0 + ? "< 1" + : Math.round(planUsagePct)} + % used + +
+
+
80 + ? "#ef4444" + : "linear-gradient(to right, #4BA0FA 80%, #002757 100%)", + }} + title={`$${formatUsd(usdSpent)} of $${formatUsd(usdIncluded)} used`} + /> +
+

+ {daysRemaining !== null + ? `Resets in ${daysRemaining} day${daysRemaining !== 1 ? "s" : ""}` + : ""} +

+
+ +
+ + {cancellablePlanId && ( + + + + + +
+
+
+

+ Cancel {planDisplayNames[currentPlan]}{" "} + subscription? +

+

+ You'll keep Pro features until the end of + your current billing period + {daysRemaining !== null + ? ` (${daysRemaining} day${daysRemaining !== 1 ? "s" : ""} remaining)` + : ""} + . After that, your account will switch to the + Free plan. +

+
+ + + +
+ +
+ + + + +
+
+
+ +
+ )} +
+ + ) : ( + <> +
+

+ Free Plan +

+

+ You are on basic plan +

+
+ +
+
+

+ Plan usage +

+

+ {planUsagePct < 1 && planUsagePct > 0 + ? "< 1" + : Math.round(planUsagePct)} + % used +

+
+
+
80 ? "#ef4444" : "#0054AD", + }} + title={`$${formatUsd(usdSpent)} of $${formatUsd(usdIncluded)} used`} + /> +
+

+ {daysRemaining !== null + ? `Resets in ${daysRemaining} day${daysRemaining !== 1 ? "s" : ""}` + : ""} +

+
+ + + +
+ + +
+ + )} +
+ +
+
+ ) +} diff --git a/apps/web/components/settings/connections-mcp.tsx b/apps/web/components/settings/connections-mcp.tsx index 06603e9d..3f149753 100644 --- a/apps/web/components/settings/connections-mcp.tsx +++ b/apps/web/components/settings/connections-mcp.tsx @@ -559,7 +559,7 @@ export default function ConnectionsMCP() { const isLoading = autumn.isLoading return ( -
+
{/* Supermemory Connections Section */}
}> diff --git a/apps/web/components/settings/integrations.tsx b/apps/web/components/settings/integrations.tsx index de72ad16..125cec90 100644 --- a/apps/web/components/settings/integrations.tsx +++ b/apps/web/components/settings/integrations.tsx @@ -259,7 +259,7 @@ export default function Integrations() { } return ( -
+
Integrations diff --git a/apps/web/components/settings/support.tsx b/apps/web/components/settings/support.tsx index f5f7508d..921759ef 100644 --- a/apps/web/components/settings/support.tsx +++ b/apps/web/components/settings/support.tsx @@ -103,7 +103,7 @@ export default function Support() { } return ( -
+
{/* Support & Help Section */}
Support & Help From 53ae4af5ab0f10bdb21b44c2299ab5ab71d2fc23 Mon Sep 17 00:00:00 2001 From: MaheshtheDev <38828053+MaheshtheDev@users.noreply.github.com> Date: Wed, 20 May 2026 23:38:07 +0000 Subject: [PATCH 08/32] fix(web): portless dev auth redirects and session cookies (#981) Middleware now reconstructs public URLs from portless forwarded headers and recognizes better-auth-dev session cookies so OAuth sign-in works with local API + worktree dev URLs. --- apps/web/app/(auth)/login/new/page.tsx | 35 ++++++++++++-------- apps/web/lib/url-helpers.ts | 44 ++++++++++++++++++++++++++ apps/web/middleware.ts | 18 ++++++++--- 3 files changed, 78 insertions(+), 19 deletions(-) diff --git a/apps/web/app/(auth)/login/new/page.tsx b/apps/web/app/(auth)/login/new/page.tsx index 82d0fd48..6272cd96 100644 --- a/apps/web/app/(auth)/login/new/page.tsx +++ b/apps/web/app/(auth)/login/new/page.tsx @@ -17,6 +17,7 @@ import { motion } from "motion/react" import { dmSansClassName } from "@/lib/fonts" import { cn } from "@lib/utils" import { Logo } from "@ui/assets/Logo" +import { resolveAuthRedirectUrl } from "@/lib/url-helpers" function isMcpOAuthAuthorizeContext(sp: Pick): boolean { return sp.get("response_type") === "code" && Boolean(sp.get("client_id")) @@ -113,9 +114,25 @@ export default function LoginPage() { if (sessionPending) return if (!sessionData?.session) return const sp = new URLSearchParams(oauthQueryForResume) - if (!isMcpOAuthAuthorizeContext(sp)) return - window.location.assign(buildMcpAuthorizeResumeUrl(sp)) - }, [sessionPending, sessionData?.session, oauthQueryForResume]) + if (isMcpOAuthAuthorizeContext(sp)) { + window.location.assign(buildMcpAuthorizeResumeUrl(sp)) + return + } + const redirectUrl = params.get("redirect") + if (redirectUrl) { + window.location.assign( + resolveAuthRedirectUrl(redirectUrl, window.location.origin).toString(), + ) + return + } + router.replace("/") + }, [ + sessionPending, + sessionData?.session, + oauthQueryForResume, + params, + router, + ]) // Get redirect URL from query params const redirectUrl = params.get("redirect") @@ -128,17 +145,7 @@ export default function LoginPage() { return buildMcpAuthorizeResumeUrl(params) } - let finalUrl: URL - - if (redirectUrl) { - try { - finalUrl = new URL(redirectUrl, origin) - } catch { - finalUrl = new URL(origin) - } - } else { - finalUrl = new URL(origin) - } + const finalUrl = resolveAuthRedirectUrl(redirectUrl, origin) finalUrl.searchParams.set("extension-auth-success", "true") return finalUrl.toString() diff --git a/apps/web/lib/url-helpers.ts b/apps/web/lib/url-helpers.ts index 832b8722..cfd57209 100644 --- a/apps/web/lib/url-helpers.ts +++ b/apps/web/lib/url-helpers.ts @@ -1,3 +1,47 @@ +const PROXY_LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]) + +/** Reconstruct the browser-facing URL when running behind portless (or similar). */ +export function getPublicRequestUrl(request: Request): URL { + const internal = new URL(request.url) + const forwardedHost = request.headers + .get("x-forwarded-host") + ?.split(",")[0] + ?.trim() + if (forwardedHost) { + const proto = request.headers.get("x-forwarded-proto") || "https" + return new URL( + `${proto}://${forwardedHost}${internal.pathname}${internal.search}`, + ) + } + const portlessUrl = process.env.PORTLESS_URL + if (portlessUrl) { + try { + const base = new URL(portlessUrl) + return new URL(`${base.origin}${internal.pathname}${internal.search}`) + } catch {} + } + return internal +} + +/** Map portless proxy localhost redirects back to the current public origin. */ +export function resolveAuthRedirectUrl( + redirectUrl: string | null, + origin: string, +): URL { + const fallback = new URL(origin) + if (!redirectUrl) return fallback + try { + const target = new URL(redirectUrl) + if (PROXY_LOCAL_HOSTS.has(target.hostname)) { + return new URL(`${target.pathname}${target.search}`, origin) + } + if (target.origin === origin) return target + return fallback + } catch { + return fallback + } +} + /** * Validates if a string is a valid URL. */ diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index 0473e315..4715c571 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -1,14 +1,22 @@ import { getSessionCookie } from "better-auth/cookies" import { NextResponse } from "next/server" +import { getPublicRequestUrl } from "@/lib/url-helpers" + +function getAuthSessionCookie(request: Request): string | null { + return ( + getSessionCookie(request) ?? + getSessionCookie(request, { cookiePrefix: "better-auth-dev" }) + ) +} export default async function proxy(request: Request) { console.debug("[PROXY] === PROXY START ===") - const url = new URL(request.url) + const url = getPublicRequestUrl(request) console.debug("[PROXY] Path:", url.pathname) console.debug("[PROXY] Method:", request.method) - const sessionCookie = getSessionCookie(request) + const sessionCookie = getAuthSessionCookie(request) console.debug("[PROXY] Session cookie exists:", !!sessionCookie) // Always allow access to login and waitlist pages @@ -40,9 +48,9 @@ export default async function proxy(request: Request) { console.debug( "[PROXY] No session cookie and not on public path, redirecting to /login", ) - const url = new URL("/login", request.url) - url.searchParams.set("redirect", request.url) - return NextResponse.redirect(url) + const loginUrl = new URL("/login", url.origin) + loginUrl.searchParams.set("redirect", url.toString()) + return NextResponse.redirect(loginUrl) } // TEMPORARILY DISABLED: Waitlist check From bdf23d2b5d89169d0406db1a58e799c3ce9922ae Mon Sep 17 00:00:00 2001 From: "vorflux[bot]" <249966464+vorflux[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 17:32:34 +0530 Subject: [PATCH 09/32] chore: replace play icon with sync icon on connections list (#983) Co-authored-by: MaheshtheDev <38828053+MaheshtheDev@users.noreply.github.com> --- apps/web/app/(auth)/login/new/page.tsx | 6 +++--- apps/web/components/settings/connections-mcp.tsx | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/web/app/(auth)/login/new/page.tsx b/apps/web/app/(auth)/login/new/page.tsx index 6272cd96..086cd8ac 100644 --- a/apps/web/app/(auth)/login/new/page.tsx +++ b/apps/web/app/(auth)/login/new/page.tsx @@ -36,9 +36,9 @@ function buildMcpAuthorizeResumeUrl( function AnimatedGradientBackground() { return ( -
+
) : ( - + )} )} From 7a9b155db310c5adbd3f0e1b7d30f211ac840b15 Mon Sep 17 00:00:00 2001 From: Vedant Mahajan Date: Thu, 21 May 2026 21:21:19 +0530 Subject: [PATCH 10/32] fix chat scroll and role section (#972) Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.5 --- apps/web/components/chat/index.tsx | 40 ++++++++++++++++++++++++-- apps/web/components/dashboard-view.tsx | 28 ++++++++++++------ 2 files changed, 58 insertions(+), 10 deletions(-) diff --git a/apps/web/components/chat/index.tsx b/apps/web/components/chat/index.tsx index 19103410..98dc46d7 100644 --- a/apps/web/components/chat/index.tsx +++ b/apps/web/components/chat/index.tsx @@ -248,6 +248,11 @@ export function ChatSidebar({ id: string messages: UIMessage[] } | null>(null) + const [loadedThreadScrollTarget, setLoadedThreadScrollTarget] = useState<{ + id: string + messageCount: number + lastMessageId: string | null + } | null>(null) // Adjust chat height based on scroll position (desktop only, grid mode only) useEffect(() => { @@ -298,6 +303,13 @@ export function ChatSidebar({ useEffect(() => { if (pendingThreadLoad && currentChatId === pendingThreadLoad.id) { setMessages(pendingThreadLoad.messages) + setLoadedThreadScrollTarget({ + id: pendingThreadLoad.id, + messageCount: pendingThreadLoad.messages.length, + lastMessageId: + pendingThreadLoad.messages[pendingThreadLoad.messages.length - 1] + ?.id ?? null, + }) setPendingThreadLoad(null) } }, [currentChatId, pendingThreadLoad, setMessages]) @@ -653,15 +665,39 @@ export function ChatSidebar({ } }, [queuedMessage]) - // Scroll to bottom when a new user message is added + // Scroll to bottom when a new user message is added or a thread is loaded useEffect(() => { + const lastMessageId = messages[messages.length - 1]?.id ?? null + const loadedThreadIsRendered = + loadedThreadScrollTarget && + currentChatId === loadedThreadScrollTarget.id && + messages.length === loadedThreadScrollTarget.messageCount && + lastMessageId === loadedThreadScrollTarget.lastMessageId + + if (loadedThreadIsRendered) { + // Trigger the same scroll behavior as the button after loaded messages render. + scrollToBottom() + setTimeout(scrollToBottom, 50) + setTimeout(scrollToBottom, 150) + setTimeout(() => { + scrollToBottom() + setLoadedThreadScrollTarget(null) + }, 300) + return + } const lastMessage = messages[messages.length - 1] if (lastMessage?.role === "user" && messagesContainerRef.current) { scrollToBottom() } else { checkIfScrolledToBottom() } - }, [messages, checkIfScrolledToBottom, scrollToBottom]) + }, [ + currentChatId, + loadedThreadScrollTarget, + messages, + checkIfScrolledToBottom, + scrollToBottom, + ]) useEffect(() => { const isStreaming = status === "streaming" diff --git a/apps/web/components/dashboard-view.tsx b/apps/web/components/dashboard-view.tsx index 38f13ee3..64f601fb 100644 --- a/apps/web/components/dashboard-view.tsx +++ b/apps/web/components/dashboard-view.tsx @@ -268,6 +268,22 @@ const PROFESSION_LABELS: { { value: "medical", label: "Medical" }, ] +function getChangeProfessionPrompt(profession: Profession): string { + const prompts: Record, string> = { + developer: "Not a developer?", + research: "Not a researcher?", + finance: "Not in finance?", + design: "Not a designer?", + legal: "Not in the legal field?", + marketing: "Not in marketing?", + medical: "Not in healthcare?", + } + if (profession === "default") { + return "Not your role?" + } + return prompts[profession] ?? "Not your role?" +} + // Static plugin metadata — shared between PluginPromoCard and RecommendedPluginsCard const PLUGIN_STATIC = [ { @@ -353,7 +369,7 @@ function RecommendedPluginsCard({ return PLUGIN_STATIC.map((p) => ({ ...p, connected: connected[p.id] ?? false, - onClick: onClicks[p.id]!, + onClick: onClicks[p.id] ?? (() => {}), })) }, [hasMcp, connectedProviders, onOpenPlugins, onOpenIntegrations]) @@ -446,11 +462,7 @@ function RecommendedPluginsCard({ onClick={() => setIsEditing(true)} className="text-left px-2 pb-1 text-[10px] text-fg-faint hover:text-fg-muted transition-colors cursor-pointer" > - Not a{" "} - {PROFESSION_LABELS.find( - (p) => p.value === profession, - )?.label.toLowerCase()} - ? Change → + {getChangeProfessionPrompt(profession)} Change role → )} @@ -525,7 +537,7 @@ function PluginPromoCard({ return PLUGIN_STATIC.map((p) => ({ ...p, connected: connected[p.id] ?? false, - onClick: onClicks[p.id]!, + onClick: onClicks[p.id] ?? (() => {}), })).filter((p) => !p.connected) }, [hasMcp, connectedProviders, onOpenPlugins, onOpenIntegrations]) @@ -628,7 +640,7 @@ export function DashboardView({ onOpenSearch, onOpenIntegrations, onOpenPlugins, - onNavigateToMemories, + onNavigateToMemories: _onNavigateToMemories, onNavigateToGraph, onOpenDocument, onHighlightsChat, From 7f6b28ab8397c701ef9dbb9d73bee5e03a5d4899 Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Thu, 21 May 2026 21:29:33 +0530 Subject: [PATCH 11/32] Improve quick note editor interactions & fix filter chips responsiveness (#964) Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.5 Co-authored-by: Vedant Mahajan Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> --- apps/web/components/add-document/note.tsx | 8 +- .../content/text-editor-content.tsx | 4 +- apps/web/components/document-modal/index.tsx | 23 +- apps/web/components/fullscreen-note-modal.tsx | 17 +- apps/web/components/memories-grid.tsx | 10 +- apps/web/components/quick-note-card.tsx | 245 +++++++++++------- apps/web/components/text-editor/index.tsx | 48 +++- 7 files changed, 225 insertions(+), 130 deletions(-) diff --git a/apps/web/components/add-document/note.tsx b/apps/web/components/add-document/note.tsx index 912d20a7..be805b6e 100644 --- a/apps/web/components/add-document/note.tsx +++ b/apps/web/components/add-document/note.tsx @@ -20,11 +20,9 @@ export function NoteContent({ const [content, setContent] = useState(initialContent ?? "") const [seededContent] = useState(initialContent || undefined) - const canSubmit = content.trim().length > 0 && !isSubmitting - - const handleSubmit = () => { - if (canSubmit && onSubmit) { - onSubmit(content) + const handleSubmit = (submittedContent = content) => { + if (submittedContent.trim() && !isSubmitting && onSubmit) { + onSubmit(submittedContent) } } diff --git a/apps/web/components/document-modal/content/text-editor-content.tsx b/apps/web/components/document-modal/content/text-editor-content.tsx index 1e4c1d05..b5f51813 100644 --- a/apps/web/components/document-modal/content/text-editor-content.tsx +++ b/apps/web/components/document-modal/content/text-editor-content.tsx @@ -14,7 +14,7 @@ export interface TextEditorProps { hasUnsavedChanges: boolean isSaving: boolean onContentChange: (content: string) => void - onSave: () => void + onSave: (content?: string) => void onReset: () => void } @@ -60,7 +60,7 @@ export function TextEditorContent({
-
+
{/* View mode toggle — segmented control */}
void @@ -18,27 +19,22 @@ export function QuickNoteCard({ onMaximize, isSaving = false, }: QuickNoteCardProps) { - const textareaRef = useRef(null) + const [isExpanded, setIsExpanded] = useState(false) const { selectedProject } = useProject() const { draft, setDraft } = useQuickNoteDraft(selectedProject) - const handleChange = useCallback( - (e: React.ChangeEvent) => { - setDraft(e.target.value) - }, + const handleContentChange = useCallback( + (content: string) => setDraft(content), [setDraft], ) - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { - e.preventDefault() - if (draft.trim() && !isSaving) { - onSave(draft) - } + const handleEditorSubmit = useCallback( + (content: string) => { + if (content.trim() && !isSaving) { + onSave(content) } }, - [draft, isSaving, onSave], + [isSaving, onSave], ) const handleSaveClick = useCallback(() => { @@ -51,109 +47,172 @@ export function QuickNoteCard({ onMaximize(draft) }, [draft, onMaximize]) + const handleBlurCapture = useCallback( + (e: React.FocusEvent) => { + if (e.currentTarget.contains(e.relatedTarget as Node | null)) return + setIsExpanded(false) + }, + [], + ) + + const handleBackdropPointerDown = useCallback(() => { + const activeElement = document.activeElement + if (activeElement instanceof HTMLElement) { + activeElement.blur() + } + setIsExpanded(false) + }, []) + + useEffect(() => { + if (!isExpanded) return + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape") return + const activeElement = document.activeElement + if (activeElement instanceof HTMLElement) { + activeElement.blur() + } + setIsExpanded(false) + } + + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [isExpanded]) + const canSave = draft.trim().length > 0 && !isSaving + const hasDraft = draft.trim().length > 0 + const editorHeight = isExpanded + ? "min-h-[min(58dvh,520px)] sm:min-h-[min(54vh,560px)]" + : hasDraft + ? "min-h-[188px]" + : "min-h-[120px]" return ( -
+ <> + {isExpanded && ( +
+ )}
- - -