feat: mobile responsive, lint formats, toast, render issue fix (#688)

- Mobile responsive
- new toast design
- web document render issue fix
- posthog analytics
- ui improvements
This commit is contained in:
MaheshtheDev 2026-01-21 03:11:53 +00:00
parent 731808be68
commit 1423bd7004
119 changed files with 3966 additions and 3029 deletions

View file

@ -209,7 +209,6 @@ function addSupermemoryButtonToMemoriesDialog() {
if (memoriesDialog.querySelector("#supermemory-save-button")) return
const deleteAllContainer = memoriesDialog.querySelector(
".flex.items-center.gap-0\\.5",
)

View file

@ -4,7 +4,7 @@ import { ELEMENT_IDS, MESSAGE_TYPES, UI_CONFIG } from "../../utils/constants"
let currentQuery = ""
let fabElement: HTMLElement | null = null
let panelElement: HTMLElement | null = null
let selectedResults: Set<number> = new Set()
const selectedResults: Set<number> = new Set()
/**
* Get the selection rectangle for positioning the FAB

View file

@ -117,7 +117,10 @@ export function setupStorageListener() {
}
try {
await Promise.all([bearerToken.setValue(token), userData.setValue(user)])
await Promise.all([
bearerToken.setValue(token),
userData.setValue(user),
])
} catch {
// Do nothing
}

View file

@ -257,17 +257,20 @@ async function showOnboardingToast() {
const icon = document.createElement("img")
icon.src = iconUrl
icon.alt = "Supermemory"
icon.style.cssText = "width: 24px; height: 24px; border-radius: 4px; flex-shrink: 0; margin-top: 2px;"
icon.style.cssText =
"width: 24px; height: 24px; border-radius: 4px; flex-shrink: 0; margin-top: 2px;"
const textContainer = document.createElement("div")
textContainer.style.cssText = "display: flex; flex-direction: column; gap: 4px; flex: 1;"
textContainer.style.cssText =
"display: flex; flex-direction: column; gap: 4px; flex: 1;"
const title = document.createElement("span")
title.style.cssText = "font-weight: 600; font-size: 14px; color: #111827;"
title.textContent = "Import X/Twitter Bookmarks"
const description = document.createElement("span")
description.style.cssText = "font-size: 13px; color: #6b7280; line-height: 1.4;"
description.style.cssText =
"font-size: 13px; color: #6b7280; line-height: 1.4;"
description.textContent =
"You can import all your Twitter bookmarks to Supermemory with one click."
@ -362,10 +365,7 @@ async function showOnboardingToast() {
learnMoreButton.style.backgroundColor = "transparent"
})
learnMoreButton.addEventListener("click", () => {
window.open(
"https://docs.supermemory.ai/connectors/twitter",
"_blank",
)
window.open("https://docs.supermemory.ai/connectors/twitter", "_blank")
})
buttonsContainer.appendChild(importButton)
@ -377,7 +377,10 @@ async function showOnboardingToast() {
progressBarContainer.setAttribute("aria-valuemin", "0")
progressBarContainer.setAttribute("aria-valuemax", "100")
progressBarContainer.setAttribute("aria-valuenow", "0")
progressBarContainer.setAttribute("aria-label", "Onboarding toast auto-dismiss progress")
progressBarContainer.setAttribute(
"aria-label",
"Onboarding toast auto-dismiss progress",
)
progressBarContainer.style.cssText = `
position: absolute;
bottom: 0;
@ -394,7 +397,7 @@ async function showOnboardingToast() {
transform-origin: left;
animation: smProgressGrow ${duration}ms linear forwards;
`
// Update progress bar ARIA value as animation progresses
const startTime = Date.now()
const updateProgress = () => {

View file

@ -114,7 +114,10 @@ export async function validateAuthToken(): Promise<boolean> {
/**
* Get user data from storage
*/
export async function getUserData(): Promise<{ email?: string; name?: string } | null> {
export async function getUserData(): Promise<{
email?: string
name?: string
} | null> {
try {
return (await userData.getValue()) || null
} catch (error) {

View file

@ -33,7 +33,8 @@ export const ELEMENT_IDS = {
*/
export const STORAGE_KEYS = {
TWITTER_BOOKMARKS_ONBOARDING_SEEN: "sm_twitter_bookmarks_onboarding_seen",
TWITTER_BOOKMARKS_IMPORT_INTENT_UNTIL: "sm_twitter_bookmarks_import_intent_until",
TWITTER_BOOKMARKS_IMPORT_INTENT_UNTIL:
"sm_twitter_bookmarks_import_intent_until",
} as const
/**

View file

@ -2,7 +2,7 @@
* Centralized storage layer using WXT's built-in storage API
*/
import { storage } from '#imports';
import { storage } from "#imports"
import type { Project } from "./types"
/**
@ -118,4 +118,3 @@ export async function getTokensLogged(): Promise<boolean> {
export async function setTokensLogged(): Promise<void> {
await tokensLogged.setValue(true)
}

View file

@ -1,5 +1,5 @@
.dark img[src*="openai.svg"],
.dark img[src*="pipecat.svg"],
.dark img[src*="supermemory.svg"] {
filter: invert(1);
filter: invert(1);
}

View file

@ -224,7 +224,9 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
}
} catch (error) {
const message =
error instanceof Error ? error.message : "An unexpected error occurred"
error instanceof Error
? error.message
: "An unexpected error occurred"
return {
content: [
{

View file

@ -1,20 +1,20 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"lib": ["ESNext"],
"types": ["@cloudflare/workers-types"],
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"esModuleInterop": true,
"resolveJsonModule": true,
"outDir": "dist",
"rootDir": "src",
"baseUrl": ".",
},
"include": ["src/**/*"],
"exclude": ["node_modules"],
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"lib": ["ESNext"],
"types": ["@cloudflare/workers-types"],
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"esModuleInterop": true,
"resolveJsonModule": true,
"outDir": "dist",
"rootDir": "src",
"baseUrl": "."
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

View file

@ -9,12 +9,12 @@
"API_URL": "https://api.supermemory.ai"
},
"routes": [
{
"pattern": "mcp.supermemory.ai",
"zone_name": "supermemory.ai",
"custom_domain": true
}
],
{
"pattern": "mcp.supermemory.ai",
"zone_name": "supermemory.ai",
"custom_domain": true
}
],
"durable_objects": {
"bindings": [
{

View file

@ -31,7 +31,9 @@ export default function Home() {
// State for slideshow
const [isSlideshowActive, setIsSlideshowActive] = useState(false)
const [currentSlideshowNode, setCurrentSlideshowNode] = useState<string | null>(null)
const [currentSlideshowNode, setCurrentSlideshowNode] = useState<
string | null
>(null)
const PAGE_SIZE = 500

View file

@ -1,222 +1,222 @@
import { getPreferenceValues, showToast, Toast } from "@raycast/api";
import { getPreferenceValues, showToast, Toast } from "@raycast/api"
export interface Project {
id: string;
name: string;
containerTag: string;
description?: string;
id: string
name: string
containerTag: string
description?: string
}
export interface Memory {
id: string;
content: string;
title?: string;
url?: string;
containerTag?: string;
createdAt: string;
id: string
content: string
title?: string
url?: string
containerTag?: string
createdAt: string
}
export interface SearchResult {
documentId: string;
chunks: unknown[];
title?: string;
metadata: Record<string, unknown>;
score?: number;
createdAt: string;
updatedAt: string;
type: string;
documentId: string
chunks: unknown[]
title?: string
metadata: Record<string, unknown>
score?: number
createdAt: string
updatedAt: string
type: string
}
export interface AddMemoryRequest {
content: string;
containerTags?: string[];
title?: string;
url?: string;
metadata?: Record<string, unknown>;
content: string
containerTags?: string[]
title?: string
url?: string
metadata?: Record<string, unknown>
}
interface AddProjectRequest {
name: string;
name: string
}
export interface SearchRequest {
q: string;
containerTags?: string[];
limit?: number;
q: string
containerTags?: string[]
limit?: number
}
export interface SearchResponse {
results: SearchResult[];
timing: number;
total: number;
results: SearchResult[]
timing: number
total: number
}
const API_BASE_URL = "https://api.supermemory.ai";
const API_BASE_URL = "https://api.supermemory.ai"
class SupermemoryAPIError extends Error {
constructor(
message: string,
public status?: number,
) {
super(message);
this.name = "SupermemoryAPIError";
}
constructor(
message: string,
public status?: number,
) {
super(message)
this.name = "SupermemoryAPIError"
}
}
class AuthenticationError extends Error {
constructor(message: string) {
super(message);
this.name = "AuthenticationError";
}
constructor(message: string) {
super(message)
this.name = "AuthenticationError"
}
}
function getApiKey(): string {
const { apiKey } = getPreferenceValues<Preferences>();
return apiKey;
const { apiKey } = getPreferenceValues<Preferences>()
return apiKey
}
async function makeAuthenticatedRequest<T>(
endpoint: string,
options: RequestInit = {},
endpoint: string,
options: RequestInit = {},
): Promise<T> {
const apiKey = getApiKey();
const apiKey = getApiKey()
const url = `${API_BASE_URL}${endpoint}`;
const url = `${API_BASE_URL}${endpoint}`
try {
const response = await fetch(url, {
...options,
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
...options.headers,
},
});
try {
const response = await fetch(url, {
...options,
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
...options.headers,
},
})
if (!response.ok) {
if (response.status === 401) {
throw new AuthenticationError(
"Invalid API key. Please check your API key in preferences. Get a new one from https://supermemory.link/raycast",
);
}
if (!response.ok) {
if (response.status === 401) {
throw new AuthenticationError(
"Invalid API key. Please check your API key in preferences. Get a new one from https://supermemory.link/raycast",
)
}
let errorMessage = `API request failed: ${response.statusText}`;
try {
const errorBody = (await response.json()) as { message?: string };
if (errorBody.message) {
errorMessage = errorBody.message;
}
} catch {
// Ignore JSON parsing errors, use default message
}
let errorMessage = `API request failed: ${response.statusText}`
try {
const errorBody = (await response.json()) as { message?: string }
if (errorBody.message) {
errorMessage = errorBody.message
}
} catch {
// Ignore JSON parsing errors, use default message
}
throw new SupermemoryAPIError(errorMessage, response.status);
}
throw new SupermemoryAPIError(errorMessage, response.status)
}
if (!response.headers.get("content-type")?.includes("application/json")) {
throw new SupermemoryAPIError("Invalid response format from API");
}
if (!response.headers.get("content-type")?.includes("application/json")) {
throw new SupermemoryAPIError("Invalid response format from API")
}
const data = (await response.json()) as T;
return data;
} catch (err) {
if (
err instanceof AuthenticationError ||
err instanceof SupermemoryAPIError
) {
throw err;
}
const data = (await response.json()) as T
return data
} catch (err) {
if (
err instanceof AuthenticationError ||
err instanceof SupermemoryAPIError
) {
throw err
}
// Handle network errors or other fetch errors
throw new SupermemoryAPIError(
`Network error: ${err instanceof Error ? err.message : "Unknown error"}`,
);
}
// Handle network errors or other fetch errors
throw new SupermemoryAPIError(
`Network error: ${err instanceof Error ? err.message : "Unknown error"}`,
)
}
}
export async function fetchProjects(): Promise<Project[]> {
try {
const response = await makeAuthenticatedRequest<{ projects: Project[] }>(
"/v3/projects",
);
return response.projects || [];
} catch (error) {
await showToast({
style: Toast.Style.Failure,
title: "Failed to fetch projects",
message:
error instanceof Error ? error.message : "Unknown error occurred",
});
throw error;
}
try {
const response = await makeAuthenticatedRequest<{ projects: Project[] }>(
"/v3/projects",
)
return response.projects || []
} catch (error) {
await showToast({
style: Toast.Style.Failure,
title: "Failed to fetch projects",
message:
error instanceof Error ? error.message : "Unknown error occurred",
})
throw error
}
}
export async function addProject(request: AddProjectRequest): Promise<Project> {
const response = await makeAuthenticatedRequest<Project>("/v3/projects", {
method: "POST",
body: JSON.stringify(request),
});
const response = await makeAuthenticatedRequest<Project>("/v3/projects", {
method: "POST",
body: JSON.stringify(request),
})
await showToast({
style: Toast.Style.Success,
title: "Project Added",
message: "Successfully added project to Supermemory",
});
await showToast({
style: Toast.Style.Success,
title: "Project Added",
message: "Successfully added project to Supermemory",
})
return response;
return response
}
export async function addMemory(request: AddMemoryRequest): Promise<Memory> {
try {
const response = await makeAuthenticatedRequest<Memory>("/v3/documents", {
method: "POST",
body: JSON.stringify(request),
});
try {
const response = await makeAuthenticatedRequest<Memory>("/v3/documents", {
method: "POST",
body: JSON.stringify(request),
})
await showToast({
style: Toast.Style.Success,
title: "Memory Added",
message: "Successfully added memory to Supermemory",
});
await showToast({
style: Toast.Style.Success,
title: "Memory Added",
message: "Successfully added memory to Supermemory",
})
return response;
} catch (error) {
await showToast({
style: Toast.Style.Failure,
title: "Failed to add memory",
message:
error instanceof Error ? error.message : "Unknown error occurred",
});
throw error;
}
return response
} catch (error) {
await showToast({
style: Toast.Style.Failure,
title: "Failed to add memory",
message:
error instanceof Error ? error.message : "Unknown error occurred",
})
throw error
}
}
export async function searchMemories(
request: SearchRequest,
request: SearchRequest,
): Promise<SearchResult[]> {
try {
const response = await makeAuthenticatedRequest<SearchResponse>(
"/v3/search",
{
method: "POST",
body: JSON.stringify(request),
},
);
try {
const response = await makeAuthenticatedRequest<SearchResponse>(
"/v3/search",
{
method: "POST",
body: JSON.stringify(request),
},
)
return response.results || [];
} catch (error) {
await showToast({
style: Toast.Style.Failure,
title: "Failed to search memories",
message:
error instanceof Error ? error.message : "Unknown error occurred",
});
throw error;
}
return response.results || []
} catch (error) {
await showToast({
style: Toast.Style.Failure,
title: "Failed to search memories",
message:
error instanceof Error ? error.message : "Unknown error occurred",
})
throw error
}
}
// Helper function to check if API key is configured and valid
export async function fetchSettings(): Promise<object> {
const response = await makeAuthenticatedRequest<object>("/v3/settings");
return response;
const response = await makeAuthenticatedRequest<object>("/v3/settings")
return response
}

View file

@ -1,106 +1,106 @@
import {
ActionPanel,
List,
Action,
Icon,
Form,
useNavigation,
} from "@raycast/api";
import { useState } from "react";
import { fetchProjects, addProject } from "./api";
ActionPanel,
List,
Action,
Icon,
Form,
useNavigation,
} from "@raycast/api"
import { useState } from "react"
import { fetchProjects, addProject } from "./api"
import {
FormValidation,
showFailureToast,
useCachedPromise,
useForm,
} from "@raycast/utils";
import { withSupermemory } from "./withSupermemory";
FormValidation,
showFailureToast,
useCachedPromise,
useForm,
} from "@raycast/utils"
import { withSupermemory } from "./withSupermemory"
export default withSupermemory(Command);
export default withSupermemory(Command)
function Command() {
const { isLoading, data: projects, mutate } = useCachedPromise(fetchProjects);
const { isLoading, data: projects, mutate } = useCachedPromise(fetchProjects)
return (
<List isLoading={isLoading} searchBarPlaceholder="Search your projects">
{!isLoading && !projects?.length ? (
<List.EmptyView
title="No Projects Found"
actions={
<ActionPanel>
<Action.Push
icon={Icon.Plus}
title="Create Project"
target={<CreateProject />}
onPop={mutate}
/>
</ActionPanel>
}
/>
) : (
projects?.map((project) => (
<List.Item
key={project.id}
icon={Icon.Folder}
title={project.name}
subtitle={project.description}
accessories={[{ tag: project.containerTag }]}
actions={
<ActionPanel>
<Action.Push
icon={Icon.Plus}
title="Create Project"
target={<CreateProject />}
onPop={mutate}
/>
</ActionPanel>
}
/>
))
)}
</List>
);
return (
<List isLoading={isLoading} searchBarPlaceholder="Search your projects">
{!isLoading && !projects?.length ? (
<List.EmptyView
title="No Projects Found"
actions={
<ActionPanel>
<Action.Push
icon={Icon.Plus}
title="Create Project"
target={<CreateProject />}
onPop={mutate}
/>
</ActionPanel>
}
/>
) : (
projects?.map((project) => (
<List.Item
key={project.id}
icon={Icon.Folder}
title={project.name}
subtitle={project.description}
accessories={[{ tag: project.containerTag }]}
actions={
<ActionPanel>
<Action.Push
icon={Icon.Plus}
title="Create Project"
target={<CreateProject />}
onPop={mutate}
/>
</ActionPanel>
}
/>
))
)}
</List>
)
}
function CreateProject() {
const { pop } = useNavigation();
const [isLoading, setIsLoading] = useState(false);
const { handleSubmit, itemProps } = useForm<{ name: string }>({
async onSubmit(values) {
setIsLoading(true);
try {
await addProject(values);
pop();
} catch (error) {
await showFailureToast(error, { title: "Failed to add project" });
} finally {
setIsLoading(false);
}
},
validation: {
name: FormValidation.Required,
},
});
return (
<Form
navigationTitle="Search Projects / Add"
isLoading={isLoading}
actions={
<ActionPanel>
<Action.SubmitForm
icon={Icon.Plus}
title="Create Project"
onSubmit={handleSubmit}
/>
</ActionPanel>
}
>
<Form.TextField
title="Name"
placeholder="My Awesome Project"
info="This will help you organize your memories"
{...itemProps.name}
/>
</Form>
);
const { pop } = useNavigation()
const [isLoading, setIsLoading] = useState(false)
const { handleSubmit, itemProps } = useForm<{ name: string }>({
async onSubmit(values) {
setIsLoading(true)
try {
await addProject(values)
pop()
} catch (error) {
await showFailureToast(error, { title: "Failed to add project" })
} finally {
setIsLoading(false)
}
},
validation: {
name: FormValidation.Required,
},
})
return (
<Form
navigationTitle="Search Projects / Add"
isLoading={isLoading}
actions={
<ActionPanel>
<Action.SubmitForm
icon={Icon.Plus}
title="Create Project"
onSubmit={handleSubmit}
/>
</ActionPanel>
}
>
<Form.TextField
title="Name"
placeholder="My Awesome Project"
info="This will help you organize your memories"
{...itemProps.name}
/>
</Form>
)
}

View file

@ -1,48 +1,48 @@
import { usePromise } from "@raycast/utils";
import { fetchSettings } from "./api";
import { usePromise } from "@raycast/utils"
import { fetchSettings } from "./api"
import {
Action,
ActionPanel,
Detail,
Icon,
List,
openExtensionPreferences,
} from "@raycast/api";
import { ComponentType } from "react";
Action,
ActionPanel,
Detail,
Icon,
List,
openExtensionPreferences,
} from "@raycast/api"
import type { ComponentType } from "react"
export function withSupermemory<P extends object>(Component: ComponentType<P>) {
return function SupermemoryWrappedComponent(props: P) {
const { isLoading, data } = usePromise(fetchSettings, [], {
failureToastOptions: {
title: "Invalid API Key",
message:
"Invalid API key. Please check your API key in preferences. Get a new one from https://supermemory.link/raycast",
},
});
return function SupermemoryWrappedComponent(props: P) {
const { isLoading, data } = usePromise(fetchSettings, [], {
failureToastOptions: {
title: "Invalid API Key",
message:
"Invalid API key. Please check your API key in preferences. Get a new one from https://supermemory.link/raycast",
},
})
if (!data) {
return isLoading ? (
<Detail isLoading />
) : (
<List>
<List.EmptyView
icon={Icon.ExclamationMark}
title="API Key Required"
description="Please configure your Supermemory API key to search memories"
actions={
<ActionPanel>
<Action
title="Open Extension Preferences"
onAction={openExtensionPreferences}
icon={Icon.Gear}
/>
</ActionPanel>
}
/>
</List>
);
}
if (!data) {
return isLoading ? (
<Detail isLoading />
) : (
<List>
<List.EmptyView
icon={Icon.ExclamationMark}
title="API Key Required"
description="Please configure your Supermemory API key to search memories"
actions={
<ActionPanel>
<Action
title="Open Extension Preferences"
onAction={openExtensionPreferences}
icon={Icon.Gear}
/>
</ActionPanel>
}
/>
</List>
)
}
return <Component {...props} />;
};
return <Component {...props} />
}
}

View file

@ -1,16 +1,16 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"include": ["src/**/*", "raycast-env.d.ts"],
"compilerOptions": {
"lib": ["ES2023"],
"module": "commonjs",
"target": "ES2023",
"strict": true,
"isolatedModules": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"jsx": "react-jsx",
"resolveJsonModule": true
}
"$schema": "https://json.schemastore.org/tsconfig",
"include": ["src/**/*", "raycast-env.d.ts"],
"compilerOptions": {
"lib": ["ES2023"],
"module": "commonjs",
"target": "ES2023",
"strict": true,
"isolatedModules": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"jsx": "react-jsx",
"resolveJsonModule": true
}
}

View file

@ -9,4 +9,4 @@ export default function BillingPage() {
<BillingView />
</div>
)
}
}

View file

@ -7,4 +7,4 @@ export default function IntegrationsPage() {
<IntegrationsView />
</div>
)
}
}

View file

@ -9,4 +9,4 @@ export default function ProfilePage() {
<ProfileView />
</div>
)
}
}

View file

@ -1,5 +1,5 @@
/** biome-ignore-all lint/performance/noImgElement: Not Next.js environment */
import { ImageResponse } from "next/og";
import { ImageResponse } from "next/og"
export async function GET() {
return new ImageResponse(
@ -15,5 +15,5 @@ export async function GET() {
width: 1200,
height: 630,
},
);
)
}

View file

@ -64,7 +64,11 @@ export async function POST(req: Request) {
},
{
type: "x",
includedXHandles: [lowerUrl.replace("https://x.com/", "").replace("https://twitter.com/", "")],
includedXHandles: [
lowerUrl
.replace("https://x.com/", "")
.replace("https://twitter.com/", ""),
],
postFavoriteCount: 10,
},
],

View file

@ -8,7 +8,7 @@ import { PostHogProvider } from "@lib/posthog"
import { QueryProvider } from "../components/query-client"
import { AutumnProvider } from "autumn-js/react"
import { Suspense } from "react"
import { Toaster } from "sonner"
import { Toaster } from "@ui/components/sonner"
import { MobilePanelProvider } from "@/lib/mobile-panel-context"
import { NuqsAdapter } from "nuqs/adapters/next/app"
import { ThemeProvider } from "@/lib/theme-provider"
@ -66,7 +66,7 @@ export default function RootLayout({
<ErrorTrackingProvider>
<NuqsAdapter>
<Suspense>{children}</Suspense>
<Toaster richColors />
<Toaster />
</NuqsAdapter>
</ErrorTrackingProvider>
</PostHogProvider>

View file

@ -1,4 +1,4 @@
import type { MetadataRoute } from "next";
import type { MetadataRoute } from "next"
export default function manifest(): MetadataRoute.Manifest {
return {
@ -16,5 +16,5 @@ export default function manifest(): MetadataRoute.Manifest {
type: "image/png",
},
],
};
}
}

View file

@ -8,6 +8,7 @@ import { IntegrationsStep } from "@/components/new/onboarding/setup/integrations
import { SetupHeader } from "@/components/new/onboarding/setup/header"
import { ChatSidebar } from "@/components/new/onboarding/setup/chat-sidebar"
import { AnimatedGradientBackground } from "@/components/new/animated-gradient-background"
import { useIsMobile } from "@hooks/use-mobile"
import { useSetupContext, type SetupStep } from "./layout"
@ -32,6 +33,7 @@ function StepNotFound({ goToStep }: { goToStep: (step: SetupStep) => void }) {
export default function SetupPage() {
const { memoryFormData, currentStep, goToStep } = useSetupContext()
const isMobile = useIsMobile()
const renderStep = () => {
switch (currentStep) {
@ -52,17 +54,21 @@ export default function SetupPage() {
<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">
<div className="flex flex-col lg:flex-row h-[calc(100vh-90px)] relative">
<div className="flex-1 flex flex-col items-center justify-start p-4 md:p-8">
<AnimatePresence mode="wait">{renderStep()}</AnimatePresence>
</div>
<AnimatePresence mode="popLayout">
<ChatSidebar formData={memoryFormData} />
</AnimatePresence>
{!isMobile && (
<AnimatePresence mode="popLayout">
<ChatSidebar formData={memoryFormData} />
</AnimatePresence>
)}
</div>
</div>
</main>
{isMobile && <ChatSidebar formData={memoryFormData} />}
</div>
)
}

View file

@ -81,13 +81,13 @@ export default function WelcomePage() {
localStorage.setItem("username", name)
if (name.trim()) {
setIsSubmitting(true)
try {
await authClient.updateUser({ displayUsername: name.trim() })
} catch (error) {
console.error("Failed to update displayUsername:", error)
}
goToStep("greeting")
setIsSubmitting(false)
}

View file

@ -10,32 +10,45 @@ import { MCPModal } from "@/components/new/mcp-modal"
import { HotkeysProvider } from "react-hotkeys-hook"
import { useHotkeys } from "react-hotkeys-hook"
import { AnimatePresence } from "motion/react"
import { useIsMobile } from "@hooks/use-mobile"
import { analytics } from "@/lib/analytics"
export default function NewPage() {
const isMobile = useIsMobile()
const [isAddDocumentOpen, setIsAddDocumentOpen] = useState(false)
const [isMCPModalOpen, setIsMCPModalOpen] = useState(false)
useHotkeys("c", () => setIsAddDocumentOpen(true))
const [isChatOpen, setIsChatOpen] = useState(true)
useHotkeys("c", () => {
analytics.addDocumentModalOpened()
setIsAddDocumentOpen(true)
})
const [isChatOpen, setIsChatOpen] = useState(!isMobile)
return (
<HotkeysProvider>
<div className="bg-black">
<div className="bg-black min-h-screen">
<AnimatedGradientBackground
topPosition="15%"
animateFromBottom={false}
/>
<Header
onAddMemory={() => setIsAddDocumentOpen(true)}
onOpenMCP={() => setIsMCPModalOpen(true)}
onAddMemory={() => {
analytics.addDocumentModalOpened()
setIsAddDocumentOpen(true)
}}
onOpenMCP={() => {
analytics.mcpModalOpened()
setIsMCPModalOpen(true)
}}
onOpenChat={() => setIsChatOpen(true)}
/>
<main
key={`main-container-${isChatOpen}`}
className="z-10 flex flex-row relative"
className="z-10 flex flex-col md:flex-row relative"
>
<div className="flex-1 p-6 pr-0">
<div className="flex-1 p-4 md:p-6 md:pr-0">
<MemoriesGrid isChatOpen={isChatOpen} />
</div>
<div className="sticky top-0 h-screen">
<div className="hidden md:block md:sticky md:top-0 md:h-screen">
<AnimatePresence mode="popLayout">
<ChatSidebar
isChatOpen={isChatOpen}
@ -45,6 +58,10 @@ export default function NewPage() {
</div>
</main>
{isMobile && (
<ChatSidebar isChatOpen={isChatOpen} setIsChatOpen={setIsChatOpen} />
)}
<AddDocumentModal
isOpen={isAddDocumentOpen}
onClose={() => setIsAddDocumentOpen(false)}

View file

@ -12,6 +12,7 @@ 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"
import { useIsMobile } from "@hooks/use-mobile"
const TABS = ["account", "integrations", "connections", "support"] as const
type SettingsTab = (typeof TABS)[number]
@ -148,6 +149,7 @@ export default function SettingsPage() {
const [activeTab, setActiveTab] = useState<SettingsTab>("account")
const hasInitialized = useRef(false)
const router = useRouter()
const isMobile = useIsMobile()
useEffect(() => {
if (hasInitialized.current) return
@ -174,7 +176,7 @@ export default function SettingsPage() {
}, [])
return (
<div className="h-screen flex flex-col overflow-hidden">
<header className="flex justify-between items-center px-6 py-3 shrink-0">
<header className="flex justify-between items-center px-4 md:px-6 py-3 shrink-0">
<button
type="button"
onClick={() => router.push("/new")}
@ -191,55 +193,77 @@ export default function SettingsPage() {
)}
</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)
<main className="flex-1 min-h-0 overflow-y-auto md:overflow-hidden">
<div className="flex flex-col md:flex-row md:justify-center gap-4 md:gap-8 lg:gap-12 px-4 md:px-6 pt-4 pb-6 md:h-full">
<div className="w-full md:w-auto md:min-w-[280px] shrink-0">
{!isMobile && (
<motion.div
animate={{
scale: 1,
padding: 48,
paddingTop: 0,
}}
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)]"
}`}
transition={{
duration: 0.8,
ease: "easeOut",
delay: 0.2,
}}
className="relative flex items-center justify-center"
>
<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}
<NovaOrb size={175} className="blur-[3px]!" />
<UserSupermemory name={user?.name ?? ""} />
</motion.div>
)}
<nav
className={cn(
"flex gap-2",
isMobile
? "flex-row overflow-x-auto pb-2 scrollbar-thin"
: "flex-col",
dmSansClassName(),
)}
>
{NAV_ITEMS.map((item) => (
<button
key={item.id}
type="button"
onClick={() => {
window.location.hash = item.id
setActiveTab(item.id)
}}
className={cn(
"rounded-xl transition-colors flex items-start gap-3 shrink-0",
isMobile ? "px-3 py-2 text-sm" : "text-left p-4",
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={cn("shrink-0", !isMobile && "mt-0.5")}>
{item.icon}
</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 />}
{isMobile ? (
<span className="font-medium whitespace-nowrap">
{item.label}
</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-1 flex flex-col gap-4 md:overflow-y-auto md:max-w-2xl [scrollbar-gutter:stable] md:pr-[17px]">
{activeTab === "account" && <Account />}
{activeTab === "integrations" && <Integrations />}
{activeTab === "connections" && <ConnectionsMCP />}
{activeTab === "support" && <Support />}
</div>
</div>
</main>
</div>

View file

@ -1,20 +1,20 @@
"use client"; // Error boundaries must be Client Components
"use client" // Error boundaries must be Client Components
import { Button } from "@ui/components/button";
import { Title1Bold } from "@ui/text/title/title-1-bold";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { Button } from "@ui/components/button"
import { Title1Bold } from "@ui/text/title/title-1-bold"
import { useRouter } from "next/navigation"
import { useEffect } from "react"
export default function NotFound({
error,
}: {
error: Error & { digest?: string };
error: Error & { digest?: string }
}) {
const router = useRouter();
const router = useRouter()
useEffect(() => {
// Log the error to an error reporting service
console.error(error);
}, [error]);
console.error(error)
}, [error])
return (
<html lang="en">
@ -23,5 +23,5 @@ export default function NotFound({
<Button onClick={() => router.back()}>Go back</Button>
</body>
</html>
);
)
}

View file

@ -81,7 +81,8 @@ export function BioForm() {
Tell Supermemory about yourself
</h1>
<p className="text-lg md:text-xl text-white/80">
share with Supermemory what you do, who you are, and what you're interested in
share with Supermemory what you do, who you are, and what you're
interested in
</p>
</div>
<Textarea

View file

@ -1,229 +1,246 @@
"use client";
"use client"
import { motion, useReducedMotion } from "motion/react";
import { useEffect, useMemo, useState, memo } from "react";
import { useOnboarding } from "./onboarding-context";
import { motion, useReducedMotion } from "motion/react"
import { useEffect, useMemo, useState, memo } from "react"
import { useOnboarding } from "./onboarding-context"
interface OrbProps {
size: number;
initialX: number;
initialY: number;
duration: number;
delay: number;
revealDelay: number;
shouldReveal: boolean;
color: {
primary: string;
secondary: string;
tertiary: string;
};
size: number
initialX: number
initialY: number
duration: number
delay: number
revealDelay: number
shouldReveal: boolean
color: {
primary: string
secondary: string
tertiary: string
}
}
function FloatingOrb({ size, initialX, initialY, duration, delay, revealDelay, shouldReveal, color }: OrbProps) {
const blurPixels = Math.min(64, Math.max(24, Math.floor(size * 0.08)));
function FloatingOrb({
size,
initialX,
initialY,
duration,
delay,
revealDelay,
shouldReveal,
color,
}: OrbProps) {
const blurPixels = Math.min(64, Math.max(24, Math.floor(size * 0.08)))
const gradient = useMemo(() => {
return `radial-gradient(circle, ${color.primary} 0%, ${color.secondary} 40%, ${color.tertiary} 70%, transparent 100%)`;
}, [color.primary, color.secondary, color.tertiary]);
const gradient = useMemo(() => {
return `radial-gradient(circle, ${color.primary} 0%, ${color.secondary} 40%, ${color.tertiary} 70%, transparent 100%)`
}, [color.primary, color.secondary, color.tertiary])
const style = useMemo(() => {
return {
width: size,
height: size,
background: gradient,
filter: `blur(${blurPixels}px)`,
willChange: "transform, opacity",
mixBlendMode: "plus-lighter",
} as any;
}, [size, gradient, blurPixels]);
const style = useMemo(() => {
return {
width: size,
height: size,
background: gradient,
filter: `blur(${blurPixels}px)`,
willChange: "transform, opacity",
mixBlendMode: "plus-lighter",
} as any
}, [size, gradient, blurPixels])
const initial = useMemo(() => {
return {
x: initialX,
y: initialY,
scale: 0,
opacity: 0,
};
}, [initialX, initialY]);
const initial = useMemo(() => {
return {
x: initialX,
y: initialY,
scale: 0,
opacity: 0,
}
}, [initialX, initialY])
const animate = useMemo(() => {
if (!shouldReveal) {
return {
x: initialX,
y: initialY,
scale: 0,
opacity: 0,
};
}
return {
x: [initialX, initialX + 200, initialX - 150, initialX + 100, initialX],
y: [initialY, initialY - 180, initialY + 120, initialY - 80, initialY],
scale: [0.8, 1.2, 0.9, 1.1, 0.8],
opacity: 0.7,
};
}, [shouldReveal, initialX, initialY]);
const animate = useMemo(() => {
if (!shouldReveal) {
return {
x: initialX,
y: initialY,
scale: 0,
opacity: 0,
}
}
return {
x: [initialX, initialX + 200, initialX - 150, initialX + 100, initialX],
y: [initialY, initialY - 180, initialY + 120, initialY - 80, initialY],
scale: [0.8, 1.2, 0.9, 1.1, 0.8],
opacity: 0.7,
}
}, [shouldReveal, initialX, initialY])
const transition = useMemo(() => {
return {
x: {
duration: shouldReveal ? duration : 0,
repeat: shouldReveal ? Number.POSITIVE_INFINITY : 0,
ease: [0.42, 0, 0.58, 1],
delay: shouldReveal ? delay + revealDelay : 0,
},
y: {
duration: shouldReveal ? duration : 0,
repeat: shouldReveal ? Number.POSITIVE_INFINITY : 0,
ease: [0.42, 0, 0.58, 1],
delay: shouldReveal ? delay + revealDelay : 0,
},
scale: {
duration: shouldReveal ? duration : 0.8,
repeat: shouldReveal ? Number.POSITIVE_INFINITY : 0,
ease: shouldReveal ? [0.42, 0, 0.58, 1] : [0, 0, 0.58, 1],
delay: shouldReveal ? delay + revealDelay : revealDelay,
},
opacity: {
duration: 1.2,
ease: [0, 0, 0.58, 1],
delay: shouldReveal ? revealDelay : 0,
},
} as any;
}, [shouldReveal, duration, delay, revealDelay]);
const transition = useMemo(() => {
return {
x: {
duration: shouldReveal ? duration : 0,
repeat: shouldReveal ? Number.POSITIVE_INFINITY : 0,
ease: [0.42, 0, 0.58, 1],
delay: shouldReveal ? delay + revealDelay : 0,
},
y: {
duration: shouldReveal ? duration : 0,
repeat: shouldReveal ? Number.POSITIVE_INFINITY : 0,
ease: [0.42, 0, 0.58, 1],
delay: shouldReveal ? delay + revealDelay : 0,
},
scale: {
duration: shouldReveal ? duration : 0.8,
repeat: shouldReveal ? Number.POSITIVE_INFINITY : 0,
ease: shouldReveal ? [0.42, 0, 0.58, 1] : [0, 0, 0.58, 1],
delay: shouldReveal ? delay + revealDelay : revealDelay,
},
opacity: {
duration: 1.2,
ease: [0, 0, 0.58, 1],
delay: shouldReveal ? revealDelay : 0,
},
} as any
}, [shouldReveal, duration, delay, revealDelay])
return (
<motion.div
className="absolute rounded-full"
style={style}
initial={initial}
animate={animate}
transition={transition}
/>
);
return (
<motion.div
className="absolute rounded-full"
style={style}
initial={initial}
animate={animate}
transition={transition}
/>
)
}
const MemoFloatingOrb = memo(FloatingOrb);
const MemoFloatingOrb = memo(FloatingOrb)
export function FloatingOrbs() {
const { orbsRevealed } = useOnboarding();
const reduceMotion = useReducedMotion();
const [mounted, setMounted] = useState(false);
const [orbs, setOrbs] = useState<Array<{
id: number;
size: number;
initialX: number;
initialY: number;
duration: number;
delay: number;
revealDelay: number;
color: {
primary: string;
secondary: string;
tertiary: string;
};
}>>([]);
const { orbsRevealed } = useOnboarding()
const reduceMotion = useReducedMotion()
const [mounted, setMounted] = useState(false)
const [orbs, setOrbs] = useState<
Array<{
id: number
size: number
initialX: number
initialY: number
duration: number
delay: number
revealDelay: number
color: {
primary: string
secondary: string
tertiary: string
}
}>
>([])
useEffect(() => {
setMounted(true);
useEffect(() => {
setMounted(true)
const screenWidth = typeof window !== "undefined" ? window.innerWidth : 1200;
const screenHeight = typeof window !== "undefined" ? window.innerHeight : 800;
const screenWidth = typeof window !== "undefined" ? window.innerWidth : 1200
const screenHeight =
typeof window !== "undefined" ? window.innerHeight : 800
// Define edge zones (avoiding center)
const edgeThickness = Math.min(screenWidth, screenHeight) * 0.25; // 25% of smaller dimension
// Define edge zones (avoiding center)
const edgeThickness = Math.min(screenWidth, screenHeight) * 0.25 // 25% of smaller dimension
// Define rainbow color palette
const colorPalette = [
{ // Magenta
primary: "rgba(255, 0, 150, 0.6)",
secondary: "rgba(255, 100, 200, 0.4)",
tertiary: "rgba(255, 150, 220, 0.1)"
},
{ // Yellow
primary: "rgba(255, 235, 59, 0.6)",
secondary: "rgba(255, 245, 120, 0.4)",
tertiary: "rgba(255, 250, 180, 0.1)"
},
{ // Light Blue
primary: "rgba(100, 181, 246, 0.6)",
secondary: "rgba(144, 202, 249, 0.4)",
tertiary: "rgba(187, 222, 251, 0.1)"
},
{ // Orange (keeping original)
primary: "rgba(255, 154, 0, 0.6)",
secondary: "rgba(255, 206, 84, 0.4)",
tertiary: "rgba(255, 154, 0, 0.1)"
},
{ // Very Light Red/Pink
primary: "rgba(255, 138, 128, 0.6)",
secondary: "rgba(255, 171, 145, 0.4)",
tertiary: "rgba(255, 205, 210, 0.1)"
}
];
// Define rainbow color palette
const colorPalette = [
{
// Magenta
primary: "rgba(255, 0, 150, 0.6)",
secondary: "rgba(255, 100, 200, 0.4)",
tertiary: "rgba(255, 150, 220, 0.1)",
},
{
// Yellow
primary: "rgba(255, 235, 59, 0.6)",
secondary: "rgba(255, 245, 120, 0.4)",
tertiary: "rgba(255, 250, 180, 0.1)",
},
{
// Light Blue
primary: "rgba(100, 181, 246, 0.6)",
secondary: "rgba(144, 202, 249, 0.4)",
tertiary: "rgba(187, 222, 251, 0.1)",
},
{
// Orange (keeping original)
primary: "rgba(255, 154, 0, 0.6)",
secondary: "rgba(255, 206, 84, 0.4)",
tertiary: "rgba(255, 154, 0, 0.1)",
},
{
// Very Light Red/Pink
primary: "rgba(255, 138, 128, 0.6)",
secondary: "rgba(255, 171, 145, 0.4)",
tertiary: "rgba(255, 205, 210, 0.1)",
},
]
// Generate orb configurations positioned along edges
const newOrbs = Array.from({ length: 8 }, (_, i) => {
let x: number;
let y: number;
const zone = i % 4; // Rotate through 4 zones: top, right, bottom, left
// Generate orb configurations positioned along edges
const newOrbs = Array.from({ length: 8 }, (_, i) => {
let x: number
let y: number
const zone = i % 4 // Rotate through 4 zones: top, right, bottom, left
switch (zone) {
case 0: // Top edge
x = Math.random() * screenWidth;
y = Math.random() * edgeThickness;
break;
case 1: // Right edge
x = screenWidth - edgeThickness + Math.random() * edgeThickness;
y = Math.random() * screenHeight;
break;
case 2: // Bottom edge
x = Math.random() * screenWidth;
y = screenHeight - edgeThickness + Math.random() * edgeThickness;
break;
case 3: // Left edge
x = Math.random() * edgeThickness;
y = Math.random() * screenHeight;
break;
default:
x = Math.random() * screenWidth;
y = Math.random() * screenHeight;
}
switch (zone) {
case 0: // Top edge
x = Math.random() * screenWidth
y = Math.random() * edgeThickness
break
case 1: // Right edge
x = screenWidth - edgeThickness + Math.random() * edgeThickness
y = Math.random() * screenHeight
break
case 2: // Bottom edge
x = Math.random() * screenWidth
y = screenHeight - edgeThickness + Math.random() * edgeThickness
break
case 3: // Left edge
x = Math.random() * edgeThickness
y = Math.random() * screenHeight
break
default:
x = Math.random() * screenWidth
y = Math.random() * screenHeight
}
return {
id: i,
size: Math.random() * 300 + 200, // 200px to 500px
initialX: x,
initialY: y,
duration: Math.random() * 20 + 15, // 15-35 seconds (longer for more gentle movement)
delay: i * 0.4, // Staggered start for floating animation
revealDelay: i * 0.2, // Faster staggered reveal
color: colorPalette[i % colorPalette.length]!, // Cycle through rainbow colors
};
});
return {
id: i,
size: Math.random() * 300 + 200, // 200px to 500px
initialX: x,
initialY: y,
duration: Math.random() * 20 + 15, // 15-35 seconds (longer for more gentle movement)
delay: i * 0.4, // Staggered start for floating animation
revealDelay: i * 0.2, // Faster staggered reveal
color: colorPalette[i % colorPalette.length]!, // Cycle through rainbow colors
}
})
setOrbs(newOrbs);
}, []);
setOrbs(newOrbs)
}, [])
if (!mounted || orbs.length === 0) return null;
if (!mounted || orbs.length === 0) return null
return (
<div
className="fixed inset-0 pointer-events-none overflow-hidden"
style={{ isolation: "isolate", contain: "paint" }}
>
{orbs.map((orb) => (
<MemoFloatingOrb
key={orb.id}
size={orb.size}
initialX={orb.initialX}
initialY={orb.initialY}
duration={reduceMotion ? 0 : orb.duration}
delay={orb.delay}
revealDelay={orb.revealDelay}
shouldReveal={reduceMotion ? false : orbsRevealed}
color={orb.color}
/>
))}
</div>
);
return (
<div
className="fixed inset-0 pointer-events-none overflow-hidden"
style={{ isolation: "isolate", contain: "paint" }}
>
{orbs.map((orb) => (
<MemoFloatingOrb
key={orb.id}
size={orb.size}
initialX={orb.initialX}
initialY={orb.initialY}
duration={reduceMotion ? 0 : orb.duration}
delay={orb.delay}
revealDelay={orb.revealDelay}
shouldReveal={reduceMotion ? false : orbsRevealed}
color={orb.color}
/>
))}
</div>
)
}

View file

@ -1,44 +1,61 @@
"use client";
"use client"
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@ui/components/hover-card"
import { useOnboarding, type OnboardingStep } from "./onboarding-context";
import { useState } from "react";
import { cn } from "@lib/utils";
import { useOnboarding, type OnboardingStep } from "./onboarding-context"
import { useState } from "react"
import { cn } from "@lib/utils"
export function NavMenu({ children }: { children: React.ReactNode }) {
const { setStep, currentStep, visibleSteps, getStepNumberFor } = useOnboarding();
const [open, setOpen] = useState(false);
const LABELS: Record<OnboardingStep, string> = {
intro: "Intro",
name: "Name",
bio: "About you",
// connections: "Connections",
mcp: "MCP",
extension: "Extension",
welcome: "Welcome",
};
const navigableSteps = visibleSteps.filter(step => step !== "intro" && step !== "welcome");
return (
<HoverCard openDelay={100} open={open} onOpenChange={setOpen}>
<HoverCardTrigger className="w-fit" asChild>{children}</HoverCardTrigger>
<HoverCardContent align="start" side="left" sideOffset={24} className="origin-top-right bg-white border border-zinc-200 text-zinc-900">
<h2 className="text-zinc-900 text-sm font-medium">Go to step</h2>
<ul className="text-sm mt-2">
{navigableSteps.map((step) => (
<li key={step}>
<button type="button" className={cn("py-1.5 px-2 rounded-md hover:bg-zinc-100 w-full text-left", currentStep === step && "bg-zinc-100")} onClick={() => {
setStep(step);
setOpen(false);
}}>
{getStepNumberFor(step)}. {LABELS[step]}
</button>
</li>
))}
</ul>
</HoverCardContent>
</HoverCard>
);
}
const { setStep, currentStep, visibleSteps, getStepNumberFor } =
useOnboarding()
const [open, setOpen] = useState(false)
const LABELS: Record<OnboardingStep, string> = {
intro: "Intro",
name: "Name",
bio: "About you",
// connections: "Connections",
mcp: "MCP",
extension: "Extension",
welcome: "Welcome",
}
const navigableSteps = visibleSteps.filter(
(step) => step !== "intro" && step !== "welcome",
)
return (
<HoverCard openDelay={100} open={open} onOpenChange={setOpen}>
<HoverCardTrigger className="w-fit" asChild>
{children}
</HoverCardTrigger>
<HoverCardContent
align="start"
side="left"
sideOffset={24}
className="origin-top-right bg-white border border-zinc-200 text-zinc-900"
>
<h2 className="text-zinc-900 text-sm font-medium">Go to step</h2>
<ul className="text-sm mt-2">
{navigableSteps.map((step) => (
<li key={step}>
<button
type="button"
className={cn(
"py-1.5 px-2 rounded-md hover:bg-zinc-100 w-full text-left",
currentStep === step && "bg-zinc-100",
)}
onClick={() => {
setStep(step)
setOpen(false)
}}
>
{getStepNumberFor(step)}. {LABELS[step]}
</button>
</li>
))}
</ul>
</HoverCardContent>
</HoverCard>
)
}

View file

@ -1,40 +1,40 @@
"use client";
"use client"
import { $fetch } from "@lib/api";
import { Button } from "@ui/components/button";
import { $fetch } from "@lib/api"
import { Button } from "@ui/components/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@ui/components/card";
import { CheckIcon, CopyIcon, LoaderIcon, ShareIcon } from "lucide-react";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "sonner";
} from "@ui/components/card"
import { CheckIcon, CopyIcon, LoaderIcon, ShareIcon } from "lucide-react"
import Link from "next/link"
import { useParams, useRouter } from "next/navigation"
import { useEffect, useState } from "react"
import { toast } from "sonner"
export default function ReferralPage() {
const router = useRouter();
const params = useParams();
const referralCode = params.code as string;
const router = useRouter()
const params = useParams()
const referralCode = params.code as string
const [isLoading, setIsLoading] = useState(true);
const [isLoading, setIsLoading] = useState(true)
const [referralData, setReferralData] = useState<{
referrerName?: string;
valid: boolean;
} | null>(null);
const [copiedLink, setCopiedLink] = useState(false);
referrerName?: string
valid: boolean
} | null>(null)
const [copiedLink, setCopiedLink] = useState(false)
const referralLink = `https://supermemory.ai/ref/${referralCode}`;
const referralLink = `https://supermemory.ai/ref/${referralCode}`
// Verify referral code and get referrer info
useEffect(() => {
async function checkReferral() {
if (!referralCode) {
setIsLoading(false);
return;
setIsLoading(false)
return
}
try {
@ -43,30 +43,28 @@ export default function ReferralPage() {
setReferralData({
valid: true,
referrerName: "A supermemory user", // Placeholder - should come from API
});
})
} catch (error) {
console.error("Error checking referral:", error);
setReferralData({ valid: false });
console.error("Error checking referral:", error)
setReferralData({ valid: false })
} finally {
setIsLoading(false);
setIsLoading(false)
}
}
checkReferral();
}, [referralCode]);
checkReferral()
}, [referralCode])
const handleCopyLink = async () => {
try {
await navigator.clipboard.writeText(referralLink);
setCopiedLink(true);
toast.success("Referral link copied!");
setTimeout(() => setCopiedLink(false), 2000);
await navigator.clipboard.writeText(referralLink)
setCopiedLink(true)
toast.success("Referral link copied!")
setTimeout(() => setCopiedLink(false), 2000)
} catch (error) {
toast.error("Failed to copy link");
toast.error("Failed to copy link")
}
};
}
const handleShare = () => {
if (navigator.share) {
@ -74,11 +72,11 @@ export default function ReferralPage() {
title: "Join supermemory",
text: "I'm excited about supermemory - it's going to change how we store and interact with our memories!",
url: referralLink,
});
})
} else {
handleCopyLink();
handleCopyLink()
}
};
}
if (isLoading) {
return (
@ -88,7 +86,7 @@ export default function ReferralPage() {
<p className="text-white/60">Checking invitation...</p>
</div>
</div>
);
)
}
if (!referralData?.valid) {
@ -112,7 +110,7 @@ export default function ReferralPage() {
</CardContent>
</Card>
</div>
);
)
}
return (
@ -146,7 +144,6 @@ export default function ReferralPage() {
</p>
</div>
<div className="text-center">
<Link
href="https://supermemory.ai"
@ -204,5 +201,5 @@ export default function ReferralPage() {
</Card>
</div>
</div>
);
)
}

View file

@ -1,15 +1,15 @@
"use client";
"use client"
import { Button } from "@ui/components/button";
import { Button } from "@ui/components/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@ui/components/card";
import { ShareIcon } from "lucide-react";
import Link from "next/link";
} from "@ui/components/card"
import { ShareIcon } from "lucide-react"
import Link from "next/link"
export default function ReferralHomePage() {
return (
@ -52,5 +52,5 @@ export default function ReferralHomePage() {
</CardContent>
</Card>
</div>
);
)
}

View file

@ -16,4 +16,4 @@
}
}
}
}
}

View file

@ -1,8 +1,8 @@
import { motion } from "motion/react";
import { motion } from "motion/react"
interface GlassMenuEffectProps {
rounded?: string;
className?: string;
rounded?: string
className?: string
}
export function GlassMenuEffect({
@ -33,5 +33,5 @@ export function GlassMenuEffect({
}}
/>
</motion.div>
);
)
}

View file

@ -15,7 +15,9 @@ export function InitialHeader({
<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-[#8B8B8B] text-[11px] leading-tight">
{userName}
</p>
<p className="text-white font-bold text-xl leading-none -mt-1">
supermemory
</p>

View file

@ -1,69 +1,69 @@
import { Button } from "@repo/ui/components/button";
import { Download, Share, X } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useState } from "react";
import { Button } from "@repo/ui/components/button"
import { Download, Share, X } from "lucide-react"
import { AnimatePresence, motion } from "motion/react"
import { useEffect, useState } from "react"
export function InstallPrompt() {
const [isIOS, setIsIOS] = useState(false);
const [showPrompt, setShowPrompt] = useState(false);
const [deferredPrompt, setDeferredPrompt] = useState<any>(null);
const [isIOS, setIsIOS] = useState(false)
const [showPrompt, setShowPrompt] = useState(false)
const [deferredPrompt, setDeferredPrompt] = useState<any>(null)
useEffect(() => {
const isIOSDevice =
/iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream;
/iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream
const isInStandaloneMode = window.matchMedia(
"(display-mode: standalone)",
).matches;
).matches
const hasSeenPrompt =
localStorage.getItem("install-prompt-dismissed") === "true";
localStorage.getItem("install-prompt-dismissed") === "true"
setIsIOS(isIOSDevice);
setIsIOS(isIOSDevice)
const isDevelopment = process.env.NODE_ENV === "development";
const isDevelopment = process.env.NODE_ENV === "development"
setShowPrompt(
!hasSeenPrompt &&
(isDevelopment ||
(!isInStandaloneMode &&
(isIOSDevice || "serviceWorker" in navigator))),
);
)
const handleBeforeInstallPrompt = (e: Event) => {
e.preventDefault();
setDeferredPrompt(e);
e.preventDefault()
setDeferredPrompt(e)
if (!hasSeenPrompt) {
setShowPrompt(true);
setShowPrompt(true)
}
};
}
window.addEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
window.addEventListener("beforeinstallprompt", handleBeforeInstallPrompt)
return () => {
window.removeEventListener(
"beforeinstallprompt",
handleBeforeInstallPrompt,
);
};
}, []);
)
}
}, [])
const handleInstall = async () => {
if (deferredPrompt) {
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
deferredPrompt.prompt()
const { outcome } = await deferredPrompt.userChoice
if (outcome === "accepted") {
localStorage.setItem("install-prompt-dismissed", "true");
setShowPrompt(false);
localStorage.setItem("install-prompt-dismissed", "true")
setShowPrompt(false)
}
setDeferredPrompt(null);
setDeferredPrompt(null)
}
};
}
const handleDismiss = () => {
localStorage.setItem("install-prompt-dismissed", "true");
setShowPrompt(false);
};
localStorage.setItem("install-prompt-dismissed", "true")
setShowPrompt(false)
}
if (!showPrompt) {
return null;
return null
}
return (
@ -128,5 +128,5 @@ export function InstallPrompt() {
</div>
</motion.div>
</AnimatePresence>
);
)
}

View file

@ -48,7 +48,7 @@ export const getSourceUrl = (document: DocumentWithMemories) => {
if (document.type === "google_slide" && document.customId) {
return `https://docs.google.com/presentation/d/${document.customId}`
}
if(document.metadata?.website_url) {
if (document.metadata?.website_url) {
return document.metadata?.website_url as string
}
// Fallback to existing URL for all other document types

View file

@ -66,7 +66,10 @@ function Menu({ id }: { id?: string }) {
const autumn = useCustomer()
const { setIsOpen } = useChatOpen()
const { data: memoriesCheck } = fetchMemoriesFeature(autumn, !autumn.isLoading)
const { data: memoriesCheck } = fetchMemoriesFeature(
autumn,
!autumn.isLoading,
)
const memoriesUsed = memoriesCheck?.usage ?? 0
const memoriesLimit = memoriesCheck?.included_usage ?? 0

View file

@ -16,6 +16,7 @@ import { useDocumentMutations } from "../../../hooks/use-document-mutations"
import { useCustomer } from "autumn-js/react"
import { useMemoriesUsage } from "@/hooks/use-memories-usage"
import { SpaceSelector } from "../space-selector"
import { useIsMobile } from "@hooks/use-mobile"
type TabType = "note" | "link" | "file" | "connect"
@ -30,11 +31,16 @@ export function AddDocumentModal({
onClose,
defaultTab,
}: AddDocumentModalProps) {
const isMobile = useIsMobile()
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]",
"border-none bg-[#1B1F24] flex flex-col p-3 md:p-4 gap-3",
isMobile
? "w-[calc(100vw-1rem)]! h-[calc(100dvh-1rem)]! max-w-none! max-h-none! rounded-xl"
: "w-[80%]! max-w-[1000px]! h-[80%]! max-h-[800px]! rounded-[22px]",
dmSansClassName(),
)}
style={{
@ -93,6 +99,7 @@ export function AddDocument({
onClose: () => void
isOpen?: boolean
}) {
const isMobile = useIsMobile()
const [activeTab, setActiveTab] = useState<TabType>(defaultTab ?? "note")
const { selectedProject: globalSelectedProject } = useProject()
const [localSelectedProject, setLocalSelectedProject] = useState<string>(
@ -208,9 +215,21 @@ export function AddDocument({
noteMutation.isPending || linkMutation.isPending || fileMutation.isPending
return (
<div className="h-full flex flex-row text-white space-x-5">
<div className="w-1/3 flex flex-col justify-between">
<div className="flex flex-col gap-1">
<div className="h-full flex flex-col md:flex-row text-white md:space-x-5 space-y-3 md:space-y-0">
<div
className={cn(
"flex flex-col justify-between",
isMobile ? "w-full" : "w-1/3",
)}
>
<div
className={cn(
"flex gap-1",
isMobile
? "flex-row overflow-x-auto pb-2 scrollbar-thin"
: "flex-col",
)}
>
{tabs.map((tab) => (
<TabButton
key={tab.id}
@ -220,47 +239,55 @@ export function AddDocument({
title={tab.title}
description={tab.description}
isPro={tab.isPro}
compact={isMobile}
/>
))}
</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">
<span
className={cn(
"text-white text-[16px] font-medium",
dmSansClassName(),
)}
>
Memories
</span>
<span className={cn("text-[#737373] text-sm", dmSansClassName())}>
{isLoadingMemories
? "…"
: hasProProduct
? "Unlimited"
: `${memoriesUsed}/${memoriesLimit}`}
</span>
</div>
{!hasProProduct && (
<div className="h-1.5 bg-[#0D121A] rounded-full overflow-hidden mt-2">
<div
className="h-full bg-[#2261CA] rounded-full"
style={{ width: `${usagePercent}%` }}
/>
{!isMobile && (
<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">
<span
className={cn(
"text-white text-[16px] font-medium",
dmSansClassName(),
)}
>
Memories
</span>
<span className={cn("text-[#737373] text-sm", dmSansClassName())}>
{isLoadingMemories
? "…"
: hasProProduct
? "Unlimited"
: `${memoriesUsed}/${memoriesLimit}`}
</span>
</div>
)}
</div>
{!hasProProduct && (
<div className="h-1.5 bg-[#0D121A] rounded-full overflow-hidden mt-2">
<div
className="h-full bg-[#2261CA] rounded-full"
style={{ width: `${usagePercent}%` }}
/>
</div>
)}
</div>
)}
</div>
<div className="w-2/3 overflow-auto flex flex-col justify-between px-1 scrollbar-thin">
<div
className={cn(
"overflow-auto flex flex-col justify-between px-1 scrollbar-thin flex-1",
isMobile ? "w-full" : "w-2/3",
)}
>
{activeTab === "note" && (
<NoteContent
onSubmit={handleNoteSubmit}
@ -288,13 +315,22 @@ export function AddDocument({
{activeTab === "connect" && (
<ConnectContent selectedProject={localSelectedProject} />
)}
<div className="flex justify-between">
<SpaceSelector
value={localSelectedProject}
onValueChange={setLocalSelectedProject}
variant="insideOut"
/>
<div className="flex items-center gap-2">
<div
className={cn(
"flex gap-2 pt-3",
isMobile ? "flex-col" : "justify-between",
)}
>
{!isMobile && (
<SpaceSelector
value={localSelectedProject}
onValueChange={setLocalSelectedProject}
variant="insideOut"
/>
)}
<div
className={cn("flex items-center gap-2", isMobile && "justify-end")}
>
<Button
variant="ghost"
onClick={onClose}
@ -317,14 +353,16 @@ export function AddDocument({
) : (
<>
+ 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>
{!isMobile && (
<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>
@ -343,6 +381,7 @@ function TabButton({
title,
description,
isPro,
compact,
}: {
active: boolean
onClick: () => void
@ -350,7 +389,34 @@ function TabButton({
title: string
description: string
isPro?: boolean
compact?: boolean
}) {
if (compact) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-full text-left transition-colors whitespace-nowrap focus:outline-none focus:ring-0 shrink-0",
active ? "bg-[#14161A] shadow-inside-out" : "hover:bg-[#14161A]/50",
dmSansClassName(),
)}
>
<Icon className={cn("size-4 shrink-0 text-white")} />
<span
className={cn("font-medium text-white text-sm", dmSansClassName())}
>
{title.split(" ")[0]}
</span>
{isPro && (
<span className="bg-[#4BA0FA] text-black text-[8px] font-semibold px-1 py-0.5 rounded">
PRO
</span>
)}
</button>
)
}
return (
<button
type="button"

View file

@ -8,19 +8,57 @@ import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon, Loader2 } from "lucide-react"
import { Button } from "@ui/components/button"
import { useProjectMutations } from "@/hooks/use-project-mutations"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@ui/components/popover"
import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover"
const EMOJI_LIST = [
"📁", "📂", "🗂️", "📚", "📖", "📝", "✏️", "📌",
"🎯", "🚀", "💡", "⭐", "🔥", "💎", "🎨", "🎵",
"🏠", "💼", "🛠️", "⚙️", "🔧", "📊", "📈", "💰",
"🌟", "✨", "🌈", "🌸", "🌺", "🍀", "🌿", "🌴",
"🐶", "🐱", "🦊", "🦁", "🐼", "🐨", "🦄", "🐝",
"❤️", "💜", "💙", "💚", "💛", "🧡", "🖤", "🤍",
"📁",
"📂",
"🗂️",
"📚",
"📖",
"📝",
"✏️",
"📌",
"🎯",
"🚀",
"💡",
"⭐",
"🔥",
"💎",
"🎨",
"🎵",
"🏠",
"💼",
"🛠️",
"⚙️",
"🔧",
"📊",
"📈",
"💰",
"🌟",
"✨",
"🌈",
"🌸",
"🌺",
"🍀",
"🌿",
"🌴",
"🐶",
"🐱",
"🦊",
"🦁",
"🐼",
"🐨",
"🦄",
"🐝",
"❤️",
"💜",
"💙",
"💚",
"💛",
"🧡",
"🖤",
"🤍",
]
export function AddSpaceModal({
@ -56,7 +94,11 @@ export function AddSpaceModal({
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && spaceName.trim() && !createProjectMutation.isPending) {
if (
e.key === "Enter" &&
spaceName.trim() &&
!createProjectMutation.isPending
) {
e.preventDefault()
handleCreate()
}
@ -83,11 +125,21 @@ export function AddSpaceModal({
<div className="flex flex-col gap-4">
<div className="flex justify-between items-start gap-4">
<div className="pl-1 space-y-1 flex-1">
<p className={cn("font-semibold text-[#fafafa]", dmSans125ClassName())}>
<p
className={cn(
"font-semibold text-[#fafafa]",
dmSans125ClassName(),
)}
>
Create new space
</p>
<p className={cn("text-[#737373] font-medium text-[16px] leading-[1.35]")}>
Create spaces to organize your memories and documents and create a context rich environment
<p
className={cn(
"text-[#737373] font-medium text-[16px] leading-[1.35]",
)}
>
Create spaces to organize your memories and documents and create
a context rich environment
</p>
</div>
<DialogPrimitive.Close
@ -133,7 +185,8 @@ export function AddSpaceModal({
onClick={() => handleEmojiSelect(e)}
className={cn(
"size-8 flex items-center justify-center rounded-md text-lg cursor-pointer transition-colors hover:bg-[#1B1F24]",
emoji === e && "bg-[#1B1F24] ring-1 ring-[rgba(115,115,115,0.3)]",
emoji === e &&
"bg-[#1B1F24] ring-1 ring-[rgba(115,115,115,0.3)]",
)}
>
{e}

View file

@ -12,6 +12,7 @@ import {
PanelRightCloseIcon,
SearchIcon,
SquarePenIcon,
XIcon,
} from "lucide-react"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
@ -24,6 +25,8 @@ import { SuperLoader } from "../../superloader"
import { UserMessage } from "./message/user-message"
import { AgentMessage } from "./message/agent-message"
import { ChainOfThought } from "./input/chain-of-thought"
import { useIsMobile } from "@hooks/use-mobile"
import { analytics } from "@/lib/analytics"
function ChatEmptyStatePlaceholder({
onSuggestionClick,
@ -78,6 +81,7 @@ export function ChatSidebar({
isChatOpen: boolean
setIsChatOpen: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [input, setInput] = useState("")
const [selectedModel, setSelectedModel] = useState<ModelId>("gemini-2.5-pro")
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null)
@ -100,8 +104,10 @@ export function ChatSidebar({
const { selectedProject } = useProject()
const { setCurrentChatId } = usePersistentChat()
// Adjust chat height based on scroll position
// Adjust chat height based on scroll position (desktop only)
useEffect(() => {
if (isMobile) return
const handleWindowScroll = () => {
const scrollThreshold = 80
const scrollY = window.scrollY
@ -114,7 +120,7 @@ export function ChatSidebar({
handleWindowScroll()
return () => window.removeEventListener("scroll", handleWindowScroll)
}, [])
}, [isMobile])
const { messages, sendMessage, status, setMessages, stop } = useChat({
transport: new DefaultChatTransport({
@ -293,6 +299,7 @@ export function ChatSidebar({
}, [])
const handleNewChat = useCallback(() => {
analytics.newChatCreated()
const newId = crypto.randomUUID()
setCurrentChatId(newId)
setMessages([])
@ -374,39 +381,59 @@ export function ChatSidebar({
<motion.div
key="closed"
className={cn(
"absolute top-0 right-0 flex items-start justify-start m-4",
"flex items-start justify-start",
isMobile
? "fixed bottom-4 right-4 z-50"
: "absolute top-0 right-0 m-4",
dmSansClassName(),
)}
layoutId="chat-toggle-button"
>
<motion.button
onClick={toggleChat}
className="flex items-center gap-3 rounded-full px-3 py-1.5 text-sm font-medium border border-[#17181A] text-white cursor-pointer whitespace-nowrap"
className={cn(
"flex items-center gap-3 rounded-full px-3 py-1.5 text-sm font-medium border border-[#17181A] text-white cursor-pointer whitespace-nowrap shadow-lg",
isMobile && "px-4 py-2",
)}
style={{
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
}}
>
<NovaOrb size={24} className="blur-[0.6px]! z-10" />
Chat with Nova
{!isMobile && "Chat with Nova"}
</motion.button>
</motion.div>
) : (
<motion.div
key="open"
className={cn(
"w-[450px] bg-[#05070A] backdrop-blur-md flex flex-col rounded-2xl m-4 mt-2 border border-[#17181AB2] relative pt-4",
"bg-[#05070A] backdrop-blur-md flex flex-col border border-[#17181AB2] relative pt-4",
isMobile
? "fixed inset-0 z-50 w-full h-dvh rounded-none m-0"
: "w-[450px] rounded-2xl m-4 mt-2",
dmSansClassName(),
)}
style={{
height: `calc(100vh - ${heightOffset}px)`,
}}
initial={{ x: "100px", opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: "100px", opacity: 0 }}
style={
isMobile
? undefined
: {
height: `calc(100vh - ${heightOffset}px)`,
}
}
initial={
isMobile ? { y: "100%", opacity: 0 } : { x: "100px", opacity: 0 }
}
animate={{ x: 0, y: 0, opacity: 1 }}
exit={
isMobile ? { y: "100%", opacity: 0 } : { 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"
className={cn(
"absolute top-0 left-0 right-0 flex items-center justify-between pt-4 px-4",
!isMobile && "rounded-t-2xl",
)}
style={{
background:
"linear-gradient(180deg, #0A0E14 40.49%, rgba(10, 14, 20, 0.00) 100%)",
@ -417,15 +444,17 @@ export function ChatSidebar({
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>
{!isMobile && (
<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"
@ -436,21 +465,38 @@ export function ChatSidebar({
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>
{!isMobile && (
<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"
className={cn(
"flex items-center gap-2 rounded-full p-2 text-xs text-white cursor-pointer",
isMobile && "bg-[#0D121A] border border-[#73737333]",
)}
style={
isMobile
? {
boxShadow:
"1.5px 1.5px 4.5px 0 rgba(0, 0, 0, 0.70) inset",
}
: undefined
}
layoutId="chat-toggle-button"
>
<PanelRightCloseIcon className="size-4" />
{isMobile ? (
<XIcon className="size-4" />
) : (
<PanelRightCloseIcon className="size-4" />
)}
</motion.button>
</div>
</div>

View file

@ -42,7 +42,13 @@ function extractTextFromNode(node: TipTapNode): string {
}
}
const blockTypes = ["paragraph", "heading", "listItem", "blockquote", "codeBlock"]
const blockTypes = [
"paragraph",
"heading",
"listItem",
"blockquote",
"codeBlock",
]
if (blockTypes.includes(node.type)) {
return `${texts.join("")}\n`
}

View file

@ -1,9 +1,27 @@
import React from "react"
import { Streamdown } from "streamdown"
const components = {
p: ({ children, ...props }: React.ComponentPropsWithoutRef<"p">) => {
const hasDiv = React.Children.toArray(children).some(
(child) =>
React.isValidElement(child) &&
typeof child.type === "string" &&
child.type === "div",
)
if (hasDiv) {
return <div {...props}>{children}</div>
}
return <p {...props}>{children}</p>
},
} as const
export function NotionDoc({ content }: { content: string }) {
return (
<div className="p-4 overflow-y-auto flex-1 scrollbar-thin">
<Streamdown>{content}</Streamdown>
<Streamdown components={components}>{content}</Streamdown>
</div>
)
}
}

View file

@ -54,7 +54,8 @@ export function PdfViewer({ url }: PdfViewerProps) {
<div className="flex-1 overflow-auto w-full">
<Document
file={
url || "https://corsproxy.io/?" +
url ||
"https://corsproxy.io/?" +
encodeURIComponent("http://www.pdf995.com/samples/pdf.pdf")
}
onLoadSuccess={onDocumentLoadSuccess}

View file

@ -0,0 +1,27 @@
import React from "react"
import { Streamdown } from "streamdown"
const components = {
p: ({ children, ...props }: React.ComponentPropsWithoutRef<"p">) => {
const hasDiv = React.Children.toArray(children).some(
(child) =>
React.isValidElement(child) &&
typeof child.type === "string" &&
child.type === "div",
)
if (hasDiv) {
return <div {...props}>{children}</div>
}
return <p {...props}>{children}</p>
},
} as const
export function WebPageContent({ content }: { content: string }) {
return (
<div className="p-4 overflow-y-auto flex-1 scrollbar-thin">
<Streamdown components={components}>{content}</Streamdown>
</div>
)
}

View file

@ -2,7 +2,13 @@
import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import { ArrowUpRightIcon, XIcon, Loader2, Trash2Icon, CheckIcon } from "lucide-react"
import {
ArrowUpRightIcon,
XIcon,
Loader2,
Trash2Icon,
CheckIcon,
} from "lucide-react"
import type { z } from "zod"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { cn } from "@lib/utils"
@ -22,6 +28,8 @@ import { Button } from "@repo/ui/components/button"
import { useDocumentMutations } from "@/hooks/use-document-mutations"
import type { UseMutationResult } from "@tanstack/react-query"
import { toast } from "sonner"
import { WebPageContent } from "./content/web-page"
import { useIsMobile } from "@hooks/use-mobile"
// Dynamically importing to prevent DOMMatrix error
const PdfViewer = dynamic(
@ -61,7 +69,11 @@ function isTemporaryId(id: string | null | undefined): boolean {
return id.startsWith("temp-") || id.startsWith("temp-file-")
}
function DeleteButton({ documentId, customId, deleteMutation }: DeleteButtonProps) {
function DeleteButton({
documentId,
customId,
deleteMutation,
}: DeleteButtonProps) {
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
const handleDelete = useCallback(() => {
@ -140,6 +152,7 @@ export function DocumentModal({
isOpen,
onClose,
}: DocumentModalProps) {
const isMobile = useIsMobile()
const { updateMutation, deleteMutation } = useDocumentMutations({ onClose })
const { initialEditorContent, initialEditorString } = useMemo(() => {
@ -181,7 +194,9 @@ export function DocumentModal({
if (!_document?.id) return
updateMutation.mutate(
{ documentId: _document.id, content: draftContentString },
{ onSuccess: (_data, variables) => setLastSavedContent(variables.content) },
{
onSuccess: (_data, variables) => setLastSavedContent(variables.content),
},
)
}, [_document?.id, draftContentString, updateMutation])
@ -189,7 +204,10 @@ export function DocumentModal({
<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]",
"p-0 border-none bg-[#1B1F24] flex flex-col px-3 md:px-4 pt-3 pb-4 gap-3",
isMobile
? "w-[calc(100vw-1rem)]! h-[calc(100dvh-1rem)]! max-w-none! max-h-none! rounded-xl"
: "w-[80%]! max-w-[1158px]! h-[86%]! max-h-[684px]! rounded-[22px]",
dmSansClassName(),
)}
style={{
@ -201,7 +219,7 @@ export function DocumentModal({
<DialogTitle className="sr-only">
{_document?.title} - Document
</DialogTitle>
<div className="flex items-center justify-between h-fit gap-4">
<div className="flex items-center justify-between h-fit gap-2 md:gap-4">
<div className="flex-1 min-w-0">
<Title
title={_document?.title}
@ -209,7 +227,7 @@ export function DocumentModal({
url={_document?.url}
/>
</div>
<div className="flex items-center gap-2 shrink-0">
<div className="flex items-center gap-1.5 md:gap-2 shrink-0">
<DeleteButton
documentId={_document?.id}
customId={_document?.customId}
@ -220,9 +238,14 @@ export function DocumentModal({
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)]"
className={cn(
"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)]",
isMobile ? "w-7 h-7 justify-center" : "px-3 py-2",
)}
>
Visit source
{!isMobile && (
<span className="line-clamp-1">Visit source</span>
)}
<ArrowUpRightIcon className="w-4 h-4 text-[#737373]" />
</a>
)}
@ -237,7 +260,7 @@ export function DocumentModal({
</DialogPrimitive.Close>
</div>
</div>
<div className="flex-1 grid grid-cols-[2fr_1fr] gap-3 overflow-hidden min-h-0">
<div className="flex-1 grid grid-cols-1 md:grid-cols-[2fr_1fr] gap-3 overflow-hidden min-h-0">
<div
id="document-preview"
className={cn(
@ -322,6 +345,9 @@ export function DocumentModal({
{_document?.url?.includes("youtube.com") && (
<YoutubeVideo url={_document.url} />
)}
{_document?.type === "webpage" && (
<WebPageContent content={_document.content ?? ""} />
)}
</div>
<div
id="document-memories-summary"

View file

@ -12,6 +12,9 @@ import {
Home,
Code2,
ExternalLink,
HelpCircle,
MenuIcon,
MessageCircleIcon,
} from "lucide-react"
import { Button } from "@ui/components/button"
import { cn } from "@lib/utils"
@ -21,6 +24,7 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@ui/components/dropdown-menu"
import { authClient } from "@lib/auth"
@ -29,17 +33,20 @@ import { useProject } from "@/stores"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { SpaceSelector } from "./space-selector"
import { useIsMobile } from "@hooks/use-mobile"
interface HeaderProps {
onAddMemory?: () => void
onOpenMCP?: () => void
onOpenChat?: () => void
}
export function Header({ onAddMemory, onOpenMCP }: HeaderProps) {
export function Header({ onAddMemory, onOpenMCP, onOpenChat }: HeaderProps) {
const { user } = useAuth()
const { selectedProject } = useProject()
const { switchProject } = useProjectMutations()
const router = useRouter()
const isMobile = useIsMobile()
const displayName =
user?.displayUsername ||
@ -48,16 +55,16 @@ export function Header({ onAddMemory, onOpenMCP }: HeaderProps) {
""
const userName = displayName ? `${displayName.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 p-3 md:p-4 justify-between items-center gap-2">
<div className="flex items-center justify-center gap-2 md:gap-4 z-10! min-w-0">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex items-center rounded-lg px-2 py-1.5 -ml-2 cursor-pointer hover:bg-white/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 transition-colors"
className="flex items-center rounded-lg px-2 py-1.5 -ml-2 cursor-pointer hover:bg-white/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 transition-colors shrink-0"
>
<Logo className="h-7" />
{userName && (
{!isMobile && userName && (
<div className="flex flex-col items-start justify-center ml-2">
<p className="text-[#8B8B8B] text-[11px] leading-tight">
{userName}
@ -71,143 +78,276 @@ export function Header({ onAddMemory, onOpenMCP }: HeaderProps) {
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="w-56 bg-[#0D121A] rounded-xl border-none p-1.5 ml-4 shadow-[0_0_20px_rgba(255,255,255,0.15)]"
className={cn(
"min-w-[200px] p-1.5 rounded-xl border border-[#2E3033] shadow-[0px_1.5px_20px_0px_rgba(0,0,0,0.65)]",
dmSansClassName(),
)}
style={{
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
}}
>
<DropdownMenuItem
asChild
className="px-3 py-2 rounded-md hover:bg-[#293952]/40 cursor-pointer"
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
>
<Link href="/new">
<Home className="h-4 w-4" />
<Home className="h-4 w-4 text-[#737373]" />
Home
</Link>
</DropdownMenuItem>
<DropdownMenuItem
asChild
className="px-3 py-2 rounded-md hover:bg-[#293952]/40 cursor-pointer"
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
>
<a
href="https://console.supermemory.ai"
target="_blank"
rel="noreferrer"
>
<Code2 className="h-4 w-4" />
<Code2 className="h-4 w-4 text-[#737373]" />
Developer console
</a>
</DropdownMenuItem>
<DropdownMenuItem
asChild
className="px-3 py-2 rounded-md hover:bg-[#293952]/40 cursor-pointer"
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
>
<a href="https://supermemory.ai" target="_blank" rel="noreferrer">
<ExternalLink className="h-4 w-4" />
<ExternalLink className="h-4 w-4 text-[#737373]" />
supermemory.ai
</a>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="self-stretch w-px bg-[#FFFFFF33]" />
<SpaceSelector
value={selectedProject}
onValueChange={switchProject}
showChevron
enableDelete
/>
<div className="self-stretch w-px bg-[#FFFFFF33] hidden md:block" />
{!isMobile && (
<SpaceSelector
value={selectedProject}
onValueChange={switchProject}
showChevron
enableDelete
/>
)}
</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"
{!isMobile && (
<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(),
)}
>
<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>
<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!">
{isMobile ? (
<>
<SpaceSelector
value={selectedProject}
onValueChange={switchProject}
showChevron
enableDelete
compact
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="headers"
className="rounded-full text-base gap-2 h-10!"
>
<MenuIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className={cn(
"min-w-[200px] p-1.5 rounded-xl border border-[#2E3033] shadow-[0px_1.5px_20px_0px_rgba(0,0,0,0.65)]",
dmSansClassName(),
)}
style={{
background:
"linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
}}
>
<DropdownMenuItem
onClick={onAddMemory}
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
>
<Plus className="h-4 w-4 text-[#737373]" />
Add memory
</DropdownMenuItem>
<DropdownMenuItem
onClick={onOpenMCP}
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
>
<Code2 className="h-4 w-4 text-[#737373]" />
MCP
</DropdownMenuItem>
<DropdownMenuItem
onClick={onOpenChat}
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
>
<MessageCircleIcon className="h-4 w-4 text-[#737373]" />
Chat with Nova
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-[#2E3033]" />
<DropdownMenuItem
onClick={() => router.push("/new/settings")}
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
>
<Settings className="h-4 w-4 text-[#737373]" />
Settings
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
) : (
<>
<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>
<button
type="button"
className="rounded-full cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 transition-transform hover:scale-105"
>
<Avatar className="border border-[#2E3033] h-8 w-8 md:h-10 md:w-10">
<AvatarImage src={user?.image ?? ""} />
<AvatarFallback className="bg-[#0D121A] text-white">
{user?.name?.charAt(0)}
</AvatarFallback>
</Avatar>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => router.push("/new/settings")}>
<Settings className="h-4 w-4" />
<DropdownMenuContent
align="end"
className={cn(
"min-w-[220px] p-1.5 rounded-xl border border-[#2E3033] shadow-[0px_1.5px_20px_0px_rgba(0,0,0,0.65)]",
dmSansClassName(),
)}
style={{
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
}}
>
<div id="user-info" className="px-3 py-2.5">
<p className="text-white text-sm font-medium truncate">
{user?.name}
</p>
<p className="text-[#737373] text-xs truncate">{user?.email}</p>
</div>
<DropdownMenuSeparator className="bg-[#2E3033]" />
<DropdownMenuItem
onClick={() => router.push("/new/settings")}
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
>
<Settings className="h-4 w-4 text-[#737373]" />
Settings
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-[#2E3033]" />
<DropdownMenuItem
asChild
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
>
<a href="mailto:support@supermemory.com">
<HelpCircle className="h-4 w-4 text-[#737373]" />
Help & Support
</a>
</DropdownMenuItem>
<DropdownMenuItem
asChild
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
>
<a
href="https://supermemory.link/discord"
target="_blank"
rel="noreferrer"
>
<svg
className="h-4 w-4 text-[#737373]"
viewBox="0 0 24 24"
fill="currentColor"
>
<title>Discord</title>
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
</svg>
Discord
</a>
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-[#2E3033]" />
<DropdownMenuItem
onClick={() => {
authClient.signOut()
router.push("/login/new")
}}
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
>
<LogOut className="h-4 w-4" />
<LogOut className="h-4 w-4 text-[#737373]" />
Logout
</DropdownMenuItem>
</DropdownMenuContent>

View file

@ -67,7 +67,11 @@ export function MCPModal({
Migrate from MCP v1
</Button>
</div>
<Button variant="insideOut" className="px-6 py-[10px]" onClick={onClose}>
<Button
variant="insideOut"
className="px-6 py-[10px]"
onClick={onClose}
>
Done
</Button>
</DialogFooter>

View file

@ -14,6 +14,7 @@ import { dmSansClassName } from "@/lib/fonts"
import { useAuth } from "@lib/auth-context"
import { useProject } from "@/stores"
import { Streamdown } from "streamdown"
import { useIsMobile } from "@hooks/use-mobile"
interface ChatSidebarProps {
formData: {
@ -35,8 +36,9 @@ interface DraftDoc {
export function ChatSidebar({ formData }: ChatSidebarProps) {
const { user } = useAuth()
const { selectedProject } = useProject()
const isMobile = useIsMobile()
const [message, setMessage] = useState("")
const [isChatOpen, setIsChatOpen] = useState(true)
const [isChatOpen, setIsChatOpen] = useState(!isMobile)
const [timelineMessages, setTimelineMessages] = useState<
{
message: string
@ -405,44 +407,73 @@ export function ChatSidebar({ formData }: ChatSidebarProps) {
<motion.div
key="closed"
className={cn(
"absolute top-0 right-0 flex items-start justify-start m-4",
"flex items-start justify-start",
isMobile
? "fixed bottom-4 right-4 z-50"
: "absolute top-0 right-0 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"
className={cn(
"flex items-center gap-2 rounded-full px-3 py-1.5 text-xs font-medium border border-[#17181A] text-white cursor-pointer shadow-lg",
isMobile && "px-4 py-2",
)}
style={{
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
}}
>
<NovaOrb size={24} className="blur-none! z-10" />
Chat with Nova
{!isMobile && "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",
"bg-[#0A0E14] backdrop-blur-md flex flex-col",
isMobile
? "fixed inset-0 z-50 w-full h-dvh rounded-none m-0"
: "w-[450px] h-[calc(100vh-110px)] rounded-2xl m-4",
dmSansClassName(),
)}
initial={{ x: "100px", opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: "100px", opacity: 0 }}
initial={
isMobile ? { y: "100%", opacity: 0 } : { x: "100px", opacity: 0 }
}
animate={{ x: 0, y: 0, opacity: 1 }}
exit={
isMobile ? { y: "100%", opacity: 0 } : { 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%)",
}}
className={cn(
"absolute top-4 right-4 flex items-center gap-2 rounded-full p-2 text-xs text-white cursor-pointer",
isMobile && "bg-[#0D121A] border border-[#73737333]",
)}
style={
isMobile
? {
boxShadow: "1.5px 1.5px 4.5px 0 rgba(0, 0, 0, 0.70) inset",
}
: {
background:
"linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
}
}
layoutId="chat-toggle-button"
>
<PanelRightCloseIcon className="size-4" />
Close chat
{isMobile ? (
<XIcon className="size-4" />
) : (
<>
<PanelRightCloseIcon className="size-4" />
Close chat
</>
)}
</motion.button>
<div className="flex-1 flex flex-col px-4 space-y-3 pb-4 justify-end overflow-y-auto scrollbar-thin">
{timelineMessages.map((msg, i) => (
@ -661,7 +692,8 @@ export function ChatSidebar({ formData }: ChatSidebarProps) {
xResearchStatus === "correct"
? "bg-green-500/20 text-green-400 border border-green-500/40"
: "bg-[#1B1F24] text-white/50 hover:text-white/70",
(isConfirmed || isLoading) && "opacity-50 cursor-not-allowed",
(isConfirmed || isLoading) &&
"opacity-50 cursor-not-allowed",
)}
>
<CheckIcon className="size-3" />

View file

@ -164,9 +164,7 @@ export function IntegrationsStep() {
<Button
variant="link"
className="text-white hover:text-gray-300 hover:no-underline cursor-pointer"
onClick={() =>
router.push("/new/onboarding/setup?step=relatable")
}
onClick={() => router.push("/new/onboarding/setup?step=relatable")}
>
← Back
</Button>

View file

@ -37,6 +37,7 @@ export interface SpaceSelectorProps {
contentClassName?: string
showNewSpace?: boolean
enableDelete?: boolean
compact?: boolean
}
const triggerVariants = {
@ -53,6 +54,7 @@ export function SpaceSelector({
contentClassName,
showNewSpace = true,
enableDelete = false,
compact = false,
}: SpaceSelectorProps) {
const [isOpen, setIsOpen] = useState(false)
const [showCreateDialog, setShowCreateDialog] = useState(false)
@ -87,7 +89,9 @@ export function SpaceSelector({
const selectedProject = useMemo(() => {
if (value === DEFAULT_PROJECT_ID) return { name: "My Space", emoji: "📁" }
const found = projects.find((p: Project) => p.containerTag === value)
return found ? { name: found.name, emoji: found.emoji } : { name: value, emoji: undefined }
return found
? { name: found.name, emoji: found.emoji }
: { name: value, emoji: undefined }
}, [projects, value])
const selectedProjectName = selectedProject.name
@ -125,7 +129,9 @@ export function SpaceSelector({
projectId: deleteDialog.project.id,
action: deleteDialog.action,
targetProjectId:
deleteDialog.action === "move" ? deleteDialog.targetProjectId : undefined,
deleteDialog.action === "move"
? deleteDialog.targetProjectId
: undefined,
},
{
onSuccess: () => {
@ -156,14 +162,14 @@ export function SpaceSelector({
p.id !== deleteDialog.project?.id &&
p.containerTag !== deleteDialog.project?.containerTag,
)
const defaultProject = projects.find(
(p: Project) => p.containerTag === DEFAULT_PROJECT_ID,
)
const isDefaultProjectBeingDeleted =
deleteDialog.project?.containerTag === DEFAULT_PROJECT_ID
if (defaultProject && !isDefaultProjectBeingDeleted) {
const defaultProjectIncluded = filtered.some(
(p: Project) => p.containerTag === DEFAULT_PROJECT_ID,
@ -172,7 +178,7 @@ export function SpaceSelector({
return [defaultProject, ...filtered]
}
}
return filtered
}, [projects, deleteDialog.project])
@ -189,10 +195,14 @@ export function SpaceSelector({
triggerClassName,
)}
>
<span className="text-sm font-bold tracking-[-0.98px]">{selectedProjectEmoji}</span>
<span className="text-sm font-medium text-white">
{isLoading ? "..." : selectedProjectName}
<span className="text-sm font-bold tracking-[-0.98px]">
{selectedProjectEmoji}
</span>
{!compact && (
<span className="text-sm font-medium text-white">
{isLoading ? "..." : selectedProjectName}
</span>
)}
{showChevron && (
<ChevronsLeftRight className="size-4 rotate-90 text-white/70" />
)}
@ -201,7 +211,7 @@ export function SpaceSelector({
<DropdownMenuContent
align="start"
className={cn(
"min-w-[200px] p-3 rounded-[11px] border border-[#2E3033] shadow-[0px_1.5px_20px_0px_rgba(0,0,0,0.65)]",
"min-w-[200px] p-1.5 rounded-xl border border-[#2E3033] shadow-[0px_1.5px_20px_0px_rgba(0,0,0,0.65)]",
dmSansClassName(),
contentClassName,
)}
@ -209,16 +219,16 @@ export function SpaceSelector({
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
}}
>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2">
<div className="flex flex-col">
{/* Default Project - no delete allowed */}
<DropdownMenuItem
onClick={() => handleSelect(DEFAULT_PROJECT_ID)}
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-md cursor-pointer text-white text-sm font-medium",
"flex items-center gap-2 px-3 py-2.5 rounded-md cursor-pointer text-white text-sm font-medium",
value === DEFAULT_PROJECT_ID
? "bg-[#161E2B] border border-[rgba(115,115,115,0.1)]"
: "opacity-50 hover:opacity-100 hover:bg-[#161E2B]/50",
? "bg-[#293952]/40"
: "opacity-60 hover:opacity-100 hover:bg-[#293952]/40",
)}
>
<span className="font-bold tracking-[-0.98px]">📁</span>
@ -233,13 +243,15 @@ export function SpaceSelector({
key={project.id}
onClick={() => handleSelect(project.containerTag)}
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-md cursor-pointer text-white text-sm font-medium group",
"flex items-center gap-2 px-3 py-2.5 rounded-md cursor-pointer text-white text-sm font-medium group",
value === project.containerTag
? "bg-[#161E2B] border border-[rgba(115,115,115,0.1)]"
: "opacity-50 hover:opacity-100 hover:bg-[#161E2B]/50",
? "bg-[#293952]/40"
: "opacity-60 hover:opacity-100 hover:bg-[#293952]/40",
)}
>
<span className="font-bold tracking-[-0.98px]">{project.emoji || "📁"}</span>
<span className="font-bold tracking-[-0.98px]">
{project.emoji || "📁"}
</span>
<span className="truncate flex-1">{project.name}</span>
{enableDelete && (
<button
@ -309,9 +321,17 @@ export function SpaceSelector({
showCloseButton={false}
>
<div className="flex flex-col gap-4">
<div id="delete-dialog-header" className="flex justify-between items-start gap-4">
<div
id="delete-dialog-header"
className="flex justify-between items-start gap-4"
>
<div className="pl-1 space-y-1 flex-1">
<p className={cn("font-semibold text-[#fafafa]", dmSans125ClassName())}>
<p
className={cn(
"font-semibold text-[#fafafa]",
dmSans125ClassName(),
)}
>
Delete space
</p>
<p className="text-[#737373] font-medium text-[16px] leading-[1.35]">
@ -325,7 +345,8 @@ export function SpaceSelector({
<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 border border-[rgba(115,115,115,0.2)] shrink-0"
style={{
boxShadow: "inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
boxShadow:
"inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
}}
>
<XIcon stroke="#737373" />
@ -337,7 +358,9 @@ export function SpaceSelector({
<button
id="move-option"
type="button"
onClick={() => setDeleteDialog((prev) => ({ ...prev, action: "move" }))}
onClick={() =>
setDeleteDialog((prev) => ({ ...prev, action: "move" }))
}
className={cn(
"flex items-center gap-3 p-3 rounded-[12px] cursor-pointer transition-colors w-full text-left",
deleteDialog.action === "move"
@ -415,7 +438,9 @@ export function SpaceSelector({
<span className="flex items-center gap-2">
<span>{p.emoji || "📁"}</span>
<span>
{p.containerTag === DEFAULT_PROJECT_ID ? "My Space" : p.name}
{p.containerTag === DEFAULT_PROJECT_ID
? "My Space"
: p.name}
</span>
</span>
</SelectItem>
@ -428,7 +453,9 @@ export function SpaceSelector({
<button
id="delete-option"
type="button"
onClick={() => setDeleteDialog((prev) => ({ ...prev, action: "delete" }))}
onClick={() =>
setDeleteDialog((prev) => ({ ...prev, action: "delete" }))
}
className={cn(
"flex items-center gap-3 p-3 rounded-[12px] cursor-pointer transition-colors w-full text-left",
deleteDialog.action === "delete"
@ -470,7 +497,10 @@ export function SpaceSelector({
)}
</div>
<div id="delete-dialog-footer" className="flex items-center justify-end gap-[22px]">
<div
id="delete-dialog-footer"
className="flex items-center justify-end gap-[22px]"
>
<button
type="button"
onClick={handleDeleteCancel}
@ -487,7 +517,8 @@ export function SpaceSelector({
onClick={handleDeleteConfirm}
disabled={
deleteProjectMutation.isPending ||
(deleteDialog.action === "move" && !deleteDialog.targetProjectId)
(deleteDialog.action === "move" &&
!deleteDialog.targetProjectId)
}
className={cn(
"px-4 py-[10px] rounded-full",
@ -498,7 +529,9 @@ export function SpaceSelector({
{deleteProjectMutation.isPending ? (
<>
<Loader2 className="size-4 animate-spin mr-2" />
{deleteDialog.action === "move" ? "Moving..." : "Deleting..."}
{deleteDialog.action === "move"
? "Moving..."
: "Deleting..."}
</>
) : deleteDialog.action === "move" ? (
"Move & Delete"

View file

@ -82,28 +82,25 @@ export function TextEditor({
}
}, [editor, initialContent])
const handleClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement
if (target.closest(".ProseMirror")) {
return
}
if (target.closest("button, a")) {
return
}
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement
if (target.closest(".ProseMirror")) {
return
}
if (target.closest("button, a")) {
return
}
const proseMirror = containerRef.current?.querySelector(
".ProseMirror",
) as HTMLElement
if (proseMirror && editorRef.current) {
setTimeout(() => {
proseMirror.focus()
editorRef.current?.commands.focus("end")
}, 0)
}
},
[],
)
const proseMirror = containerRef.current?.querySelector(
".ProseMirror",
) as HTMLElement
if (proseMirror && editorRef.current) {
setTimeout(() => {
proseMirror.focus()
editorRef.current?.commands.focus("end")
}, 0)
}
}, [])
useEffect(() => {
return () => {
@ -132,9 +129,7 @@ export function TextEditor({
<div className="flex items-center gap-1 rounded-[8px] bg-[#1b1f24] p-2 shadow-[0px_4px_20px_0px_rgba(0,0,0,0.25),inset_1px_1px_1px_0px_rgba(255,255,255,0.1)]">
<button
type="button"
onClick={() =>
editor.chain().focus().toggleBold().run()
}
onClick={() => editor.chain().focus().toggleBold().run()}
className={cn(
"flex items-center justify-center rounded-[4px] p-1.5 hover:bg-[#2e353d] cursor-pointer text-[#fafafa]",
editor.isActive("bold") && "bg-[#2e353d]",
@ -144,9 +139,7 @@ export function TextEditor({
</button>
<button
type="button"
onClick={() =>
editor.chain().focus().toggleItalic().run()
}
onClick={() => editor.chain().focus().toggleItalic().run()}
className={cn(
"flex items-center justify-center rounded-[4px] p-1.5 hover:bg-[#2e353d] cursor-pointer text-[#fafafa]",
editor.isActive("italic") && "bg-[#2e353d]",
@ -156,9 +149,7 @@ export function TextEditor({
</button>
<button
type="button"
onClick={() =>
editor.chain().focus().toggleCode().run()
}
onClick={() => editor.chain().focus().toggleCode().run()}
className={cn(
"flex items-center justify-center rounded-[4px] p-1.5 hover:bg-[#2e353d] cursor-pointer text-[#fafafa]",
editor.isActive("code") && "bg-[#2e353d]",

View file

@ -169,9 +169,7 @@ export function createSlashCommand(items: SuggestionItem[]) {
onStart: (props) => {
selectedIndex = 0
currentItems = props.items as SuggestionItem[]
currentCommand = props.command as (
item: SuggestionItem,
) => void
currentCommand = props.command as (item: SuggestionItem) => void
currentClientRect = props.clientRect ?? null
const element = document.createElement("div")
@ -208,9 +206,7 @@ export function createSlashCommand(items: SuggestionItem[]) {
onUpdate: (props) => {
currentItems = props.items as SuggestionItem[]
currentCommand = props.command as (
item: SuggestionItem,
) => void
currentCommand = props.command as (item: SuggestionItem) => void
currentClientRect = props.clientRect ?? null
if (selectedIndex >= currentItems.length) {
@ -238,9 +234,7 @@ export function createSlashCommand(items: SuggestionItem[]) {
if (event.key === "ArrowUp") {
selectedIndex =
selectedIndex <= 0
? currentItems.length - 1
: selectedIndex - 1
selectedIndex <= 0 ? currentItems.length - 1 : selectedIndex - 1
if (currentCommand) {
component?.updateProps({
items: currentItems,
@ -254,9 +248,7 @@ export function createSlashCommand(items: SuggestionItem[]) {
if (event.key === "ArrowDown") {
selectedIndex =
selectedIndex >= currentItems.length - 1
? 0
: selectedIndex + 1
selectedIndex >= currentItems.length - 1 ? 0 : selectedIndex + 1
if (currentCommand) {
component?.updateProps({
items: currentItems,

View file

@ -65,13 +65,12 @@ export function useYouTubeChannelName(url: string | undefined | null) {
})
}
export function getAbsoluteUrl(url: string): string {
try {
const urlObj = new URL(url)
return urlObj.host.replace(/^www\./, "")
} catch {
const match = url.match(/^https?:\/\/([^\/]+)/)
const match = url.match(/^https?:\/\/([^/]+)/)
const host = match?.[1] ?? url.replace(/^https?:\/\//, "")
return host.replace(/^www\./, "")
}

View file

@ -1,6 +1,6 @@
import { cn } from "@lib/utils";
import { Loader2 } from "lucide-react";
import { cn } from "@lib/utils"
import { Loader2 } from "lucide-react"
export function Spinner({ className }: { className?: string }) {
return <Loader2 className={cn("size-4 animate-spin", className)} />;
return <Loader2 className={cn("size-4 animate-spin", className)} />
}

View file

@ -1,74 +1,79 @@
'use client';
import { cn } from '@lib/utils';
import { AnimatePresence, motion, type Transition, type Variants } from 'motion/react';
import { useMemo, useId } from 'react';
"use client"
import { cn } from "@lib/utils"
import {
AnimatePresence,
motion,
type Transition,
type Variants,
} from "motion/react"
import { useMemo, useId } from "react"
export type TextMorphProps = {
children: string;
as?: React.ElementType;
className?: string;
style?: React.CSSProperties;
variants?: Variants;
transition?: Transition;
};
children: string
as?: React.ElementType
className?: string
style?: React.CSSProperties
variants?: Variants
transition?: Transition
}
export function TextMorph({
children,
as: Component = 'p',
className,
style,
variants,
transition,
children,
as: Component = "p",
className,
style,
variants,
transition,
}: TextMorphProps) {
const uniqueId = useId();
const uniqueId = useId()
const characters = useMemo(() => {
const charCounts: Record<string, number> = {};
const characters = useMemo(() => {
const charCounts: Record<string, number> = {}
return children.split('').map((char) => {
const lowerChar = char.toLowerCase();
charCounts[lowerChar] = (charCounts[lowerChar] || 0) + 1;
return children.split("").map((char) => {
const lowerChar = char.toLowerCase()
charCounts[lowerChar] = (charCounts[lowerChar] || 0) + 1
return {
id: `${uniqueId}-${lowerChar}${charCounts[lowerChar]}`,
label: char === ' ' ? '\u00A0' : char,
};
});
}, [children, uniqueId]);
return {
id: `${uniqueId}-${lowerChar}${charCounts[lowerChar]}`,
label: char === " " ? "\u00A0" : char,
}
})
}, [children, uniqueId])
const defaultVariants: Variants = {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
};
const defaultVariants: Variants = {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
}
const defaultTransition: Transition = {
type: 'spring',
stiffness: 280,
damping: 18,
mass: 0.3,
};
const defaultTransition: Transition = {
type: "spring",
stiffness: 280,
damping: 18,
mass: 0.3,
}
return (
// @ts-expect-error - style is optional
<Component className={cn(className)} aria-label={children} style={style}>
<AnimatePresence mode='popLayout' initial={false}>
{characters.map((character) => (
<motion.span
key={character.id}
layoutId={character.id}
className='inline-block'
aria-hidden='true'
initial='initial'
animate='animate'
exit='exit'
variants={variants || defaultVariants}
transition={transition || defaultTransition}
>
{character.label}
</motion.span>
))}
</AnimatePresence>
</Component>
);
return (
// @ts-expect-error - style is optional
<Component className={cn(className)} aria-label={children} style={style}>
<AnimatePresence mode="popLayout" initial={false}>
{characters.map((character) => (
<motion.span
key={character.id}
layoutId={character.id}
className="inline-block"
aria-hidden="true"
initial="initial"
animate="animate"
exit="exit"
variants={variants || defaultVariants}
transition={transition || defaultTransition}
>
{character.label}
</motion.span>
))}
</AnimatePresence>
</Component>
)
}

View file

@ -1,15 +1,15 @@
"use client";
import { cn } from "@lib/utils";
import { motion } from "motion/react";
import React, { type JSX, useMemo } from "react";
"use client"
import { cn } from "@lib/utils"
import { motion } from "motion/react"
import React, { type JSX, useMemo } from "react"
export type TextShimmerProps = {
children: string;
as?: React.ElementType;
className?: string;
duration?: number;
spread?: number;
};
children: string
as?: React.ElementType
className?: string
duration?: number
spread?: number
}
function TextShimmerComponent({
children,
@ -20,11 +20,11 @@ function TextShimmerComponent({
}: TextShimmerProps) {
const MotionComponent = motion.create(
Component as keyof JSX.IntrinsicElements,
);
)
const dynamicSpread = useMemo(() => {
return children.length * spread;
}, [children, spread]);
return children.length * spread
}, [children, spread])
return (
<MotionComponent
@ -45,13 +45,14 @@ function TextShimmerComponent({
style={
{
"--spread": `${dynamicSpread}px`,
backgroundImage: `var(--bg), linear-gradient(var(--base-color), var(--base-color))`,
backgroundImage:
"var(--bg), linear-gradient(var(--base-color), var(--base-color))",
} as React.CSSProperties
}
>
{children}
</MotionComponent>
);
)
}
export const TextShimmer = React.memo(TextShimmerComponent);
export const TextShimmer = React.memo(TextShimmerComponent)

View file

@ -420,9 +420,12 @@ export function AddMemoryView({
const formData = new FormData()
formData.append("file", file)
formData.append("containerTags", JSON.stringify([project]))
formData.append("metadata", JSON.stringify({
sm_source: "consumer",
}))
formData.append(
"metadata",
JSON.stringify({
sm_source: "consumer",
}),
)
const response = await fetch(
`${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/documents/file`,

View file

@ -1,90 +1,90 @@
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@repo/ui/components/select';
import { Plus } from 'lucide-react';
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@repo/ui/components/select"
import { Plus } from "lucide-react"
interface Project {
id?: string;
containerTag: string;
name: string;
id?: string
containerTag: string
name: string
}
interface ProjectSelectionProps {
projects: Project[];
selectedProject: string;
onProjectChange: (value: string) => void;
onCreateProject: () => void;
disabled?: boolean;
isLoading?: boolean;
className?: string;
id?: string;
projects: Project[]
selectedProject: string
onProjectChange: (value: string) => void
onCreateProject: () => void
disabled?: boolean
isLoading?: boolean
className?: string
id?: string
}
export function ProjectSelection({
projects,
selectedProject,
onProjectChange,
onCreateProject,
disabled = false,
isLoading = false,
className = '',
id = 'project-select',
projects,
selectedProject,
onProjectChange,
onCreateProject,
disabled = false,
isLoading = false,
className = "",
id = "project-select",
}: ProjectSelectionProps) {
const handleValueChange = (value: string) => {
if (value === 'create-new-project') {
onCreateProject();
} else {
onProjectChange(value);
}
};
const handleValueChange = (value: string) => {
if (value === "create-new-project") {
onCreateProject()
} else {
onProjectChange(value)
}
}
return (
<Select
key={`${id}-${selectedProject}`}
disabled={isLoading || disabled}
onValueChange={handleValueChange}
value={selectedProject}
>
<SelectTrigger
className={`bg-foreground/5 border-foreground/10 cursor-pointer ${className}`}
id={id}
>
<SelectValue placeholder="Select a project" />
</SelectTrigger>
<SelectContent position="popper" sideOffset={5} className="z-[90]">
<SelectItem
className="hover:bg-foreground/10"
key="default"
value="sm_project_default"
>
Default Project
</SelectItem>
{projects
.filter((p) => p.containerTag !== 'sm_project_default' && p.id)
.map((project) => (
<SelectItem
className="hover:bg-foreground/10"
key={project.id || project.containerTag}
value={project.containerTag}
>
{project.name}
</SelectItem>
))}
<SelectItem
className="hover:bg-foreground/10 border-t border-foreground/10 mt-1"
key="create-new"
value="create-new-project"
>
<div className="flex items-center gap-2">
<Plus className="h-4 w-4" />
<span>Create new project</span>
</div>
</SelectItem>
</SelectContent>
</Select>
);
return (
<Select
key={`${id}-${selectedProject}`}
disabled={isLoading || disabled}
onValueChange={handleValueChange}
value={selectedProject}
>
<SelectTrigger
className={`bg-foreground/5 border-foreground/10 cursor-pointer ${className}`}
id={id}
>
<SelectValue placeholder="Select a project" />
</SelectTrigger>
<SelectContent position="popper" sideOffset={5} className="z-[90]">
<SelectItem
className="hover:bg-foreground/10"
key="default"
value="sm_project_default"
>
Default Project
</SelectItem>
{projects
.filter((p) => p.containerTag !== "sm_project_default" && p.id)
.map((project) => (
<SelectItem
className="hover:bg-foreground/10"
key={project.id || project.containerTag}
value={project.containerTag}
>
{project.name}
</SelectItem>
))}
<SelectItem
className="hover:bg-foreground/10 border-t border-foreground/10 mt-1"
key="create-new"
value="create-new-project"
>
<div className="flex items-center gap-2">
<Plus className="h-4 w-4" />
<span>Create new project</span>
</div>
</SelectItem>
</SelectContent>
</Select>
)
}

View file

@ -1,28 +1,28 @@
import type { LucideIcon } from 'lucide-react';
import type { LucideIcon } from "lucide-react"
interface TabButtonProps {
icon: LucideIcon;
label: string;
isActive: boolean;
onClick: () => void;
icon: LucideIcon
label: string
isActive: boolean
onClick: () => void
}
export function TabButton({
icon: Icon,
label,
isActive,
onClick,
icon: Icon,
label,
isActive,
onClick,
}: TabButtonProps) {
return (
<button
className={`flex items-center gap-1.5 text-xs sm:text-xs px-4 sm:px-3 py-2 sm:py-1 h-8 sm:h-6 rounded-sm transition-colors whitespace-nowrap min-w-0 ${
isActive ? 'bg-white/10' : 'hover:bg-white/5'
}`}
onClick={onClick}
type="button"
>
<Icon className="h-4 w-4 sm:h-3 sm:w-3" />
{label}
</button>
);
return (
<button
className={`flex items-center gap-1.5 text-xs sm:text-xs px-4 sm:px-3 py-2 sm:py-1 h-8 sm:h-6 rounded-sm transition-colors whitespace-nowrap min-w-0 ${
isActive ? "bg-white/10" : "hover:bg-white/5"
}`}
onClick={onClick}
type="button"
>
<Icon className="h-4 w-4 sm:h-3 sm:w-3" />
{label}
</button>
)
}

View file

@ -1,8 +1,8 @@
"use client";
"use client"
import { cn } from "@lib/utils";
import { Button } from "@repo/ui/components/button";
import isHotkey from "is-hotkey";
import { cn } from "@lib/utils"
import { Button } from "@repo/ui/components/button"
import isHotkey from "is-hotkey"
import {
Bold,
Code,
@ -12,16 +12,16 @@ import {
Italic,
List,
Quote,
} from "lucide-react";
import { useCallback, useMemo, useState } from "react";
} from "lucide-react"
import { useCallback, useMemo, useState } from "react"
import {
type BaseEditor,
createEditor,
type Descendant,
Editor,
Transforms,
} from "slate";
import type { ReactEditor as ReactEditorType } from "slate-react";
} from "slate"
import type { ReactEditor as ReactEditorType } from "slate-react"
import {
Editable,
ReactEditor,
@ -29,51 +29,51 @@ import {
type RenderLeafProps,
Slate,
withReact,
} from "slate-react";
} from "slate-react"
type CustomEditor = BaseEditor & ReactEditorType;
type CustomEditor = BaseEditor & ReactEditorType
type ParagraphElement = {
type: "paragraph";
children: CustomText[];
};
type: "paragraph"
children: CustomText[]
}
type HeadingElement = {
type: "heading";
level: number;
children: CustomText[];
};
type: "heading"
level: number
children: CustomText[]
}
type ListItemElement = {
type: "list-item";
children: CustomText[];
};
type: "list-item"
children: CustomText[]
}
type BlockQuoteElement = {
type: "block-quote";
children: CustomText[];
};
type: "block-quote"
children: CustomText[]
}
type CustomElement =
| ParagraphElement
| HeadingElement
| ListItemElement
| BlockQuoteElement;
| BlockQuoteElement
type FormattedText = {
text: string;
bold?: true;
italic?: true;
code?: true;
};
text: string
bold?: true
italic?: true
code?: true
}
type CustomText = FormattedText;
type CustomText = FormattedText
declare module "slate" {
interface CustomTypes {
Editor: CustomEditor;
Element: CustomElement;
Text: CustomText;
Editor: CustomEditor
Element: CustomElement
Text: CustomText
}
}
@ -82,16 +82,16 @@ const HOTKEYS: Record<string, keyof CustomText> = {
"mod+b": "bold",
"mod+i": "italic",
"mod+`": "code",
};
}
interface TextEditorProps {
value?: string;
onChange?: (value: string) => void;
onBlur?: () => void;
placeholder?: string;
disabled?: boolean;
className?: string;
containerClassName?: string;
value?: string
onChange?: (value: string) => void
onBlur?: () => void
placeholder?: string
disabled?: boolean
className?: string
containerClassName?: string
}
const initialValue: Descendant[] = [
@ -99,114 +99,114 @@ const initialValue: Descendant[] = [
type: "paragraph",
children: [{ text: "" }],
},
];
]
const serialize = (nodes: Descendant[]): string => {
return nodes.map((n) => serializeNode(n)).join("\n");
};
return nodes.map((n) => serializeNode(n)).join("\n")
}
const serializeNode = (node: CustomElement | CustomText): string => {
if ("text" in node) {
let text = node.text;
if (node.bold) text = `**${text}**`;
if (node.italic) text = `*${text}*`;
if (node.code) text = `\`${text}\``;
return text;
let text = node.text
if (node.bold) text = `**${text}**`
if (node.italic) text = `*${text}*`
if (node.code) text = `\`${text}\``
return text
}
const children = node.children
? node.children.map(serializeNode).join("")
: "";
: ""
switch (node.type) {
case "paragraph":
return children;
return children
case "heading":
return `${"#".repeat(node.level || 1)} ${children}`;
return `${"#".repeat(node.level || 1)} ${children}`
case "list-item":
return `- ${children}`;
return `- ${children}`
case "block-quote":
return `> ${children}`;
return `> ${children}`
default:
return children;
return children
}
};
}
const deserialize = (text: string): Descendant[] => {
if (!text.trim()) {
return initialValue;
return initialValue
}
const lines = text.split("\n");
const nodes: Descendant[] = [];
const lines = text.split("\n")
const nodes: Descendant[] = []
for (const line of lines) {
const trimmedLine = line.trim();
const trimmedLine = line.trim()
if (trimmedLine.startsWith("# ")) {
nodes.push({
type: "heading",
level: 1,
children: [{ text: trimmedLine.slice(2) }],
});
})
} else if (trimmedLine.startsWith("## ")) {
nodes.push({
type: "heading",
level: 2,
children: [{ text: trimmedLine.slice(3) }],
});
})
} else if (trimmedLine.startsWith("### ")) {
nodes.push({
type: "heading",
level: 3,
children: [{ text: trimmedLine.slice(4) }],
});
})
} else if (trimmedLine.startsWith("- ")) {
nodes.push({
type: "list-item",
children: [{ text: trimmedLine.slice(2) }],
});
})
} else if (trimmedLine.startsWith("> ")) {
nodes.push({
type: "block-quote",
children: [{ text: trimmedLine.slice(2) }],
});
})
} else {
nodes.push({
type: "paragraph",
children: [{ text: line }],
});
})
}
}
return nodes.length > 0 ? nodes : initialValue;
};
return nodes.length > 0 ? nodes : initialValue
}
const isMarkActive = (editor: CustomEditor, format: keyof CustomText) => {
const marks = Editor.marks(editor);
return marks ? marks[format as keyof typeof marks] === true : false;
};
const marks = Editor.marks(editor)
return marks ? marks[format as keyof typeof marks] === true : false
}
const toggleMark = (editor: CustomEditor, format: keyof CustomText) => {
const isActive = isMarkActive(editor, format);
const isActive = isMarkActive(editor, format)
if (isActive) {
Editor.removeMark(editor, format);
Editor.removeMark(editor, format)
} else {
Editor.addMark(editor, format, true);
Editor.addMark(editor, format, true)
}
// Focus back to editor after toggling
ReactEditor.focus(editor);
};
ReactEditor.focus(editor)
}
const isBlockActive = (
editor: CustomEditor,
format: string,
level?: number,
) => {
const { selection } = editor;
if (!selection) return false;
const { selection } = editor
if (!selection) return false
const [match] = Array.from(
Editor.nodes(editor, {
@ -216,26 +216,26 @@ const isBlockActive = (
(n as CustomElement).type === format &&
(level === undefined || (n as HeadingElement).level === level),
}),
);
)
return !!match;
};
return !!match
}
const toggleBlock = (editor: CustomEditor, format: string, level?: number) => {
const isActive = isBlockActive(editor, format, level);
const isActive = isBlockActive(editor, format, level)
const newProperties: any = {
type: isActive ? "paragraph" : format,
};
if (format === "heading" && level && !isActive) {
newProperties.level = level;
}
Transforms.setNodes(editor, newProperties);
if (format === "heading" && level && !isActive) {
newProperties.level = level
}
Transforms.setNodes(editor, newProperties)
// Focus back to editor after toggling
ReactEditor.focus(editor);
};
ReactEditor.focus(editor)
}
export function TextEditor({
value = "",
@ -246,23 +246,23 @@ export function TextEditor({
className,
containerClassName,
}: TextEditorProps) {
const editor = useMemo(() => withReact(createEditor()) as CustomEditor, []);
const editor = useMemo(() => withReact(createEditor()) as CustomEditor, [])
const [editorValue, setEditorValue] = useState<Descendant[]>(() =>
deserialize(value),
);
const [selection, setSelection] = useState(editor.selection);
)
const [selection, setSelection] = useState(editor.selection)
const renderElement = useCallback((props: RenderElementProps) => {
switch (props.element.type) {
case "heading": {
const element = props.element as HeadingElement;
const element = props.element as HeadingElement
const HeadingTag = `h${element.level || 1}` as
| "h1"
| "h2"
| "h3"
| "h4"
| "h5"
| "h6";
| "h6"
return (
<HeadingTag
{...props.attributes}
@ -275,14 +275,14 @@ export function TextEditor({
>
{props.children}
</HeadingTag>
);
)
}
case "list-item":
return (
<li {...props.attributes} className="ml-4 list-disc">
{props.children}
</li>
);
)
case "block-quote":
return (
<blockquote
@ -291,88 +291,90 @@ export function TextEditor({
>
{props.children}
</blockquote>
);
)
default:
return (
<p {...props.attributes} className="mb-2">
{props.children}
</p>
);
)
}
}, []);
}, [])
const renderLeaf = useCallback((props: RenderLeafProps) => {
let { attributes, children, leaf } = props;
let { attributes, children, leaf } = props
if (leaf.bold) {
children = <strong>{children}</strong>;
children = <strong>{children}</strong>
}
if (leaf.italic) {
children = <em>{children}</em>;
children = <em>{children}</em>
}
if (leaf.code) {
children = (
<code className="bg-foreground/10 px-1 rounded text-sm">{children}</code>
);
<code className="bg-foreground/10 px-1 rounded text-sm">
{children}
</code>
)
}
return <span {...attributes}>{children}</span>;
}, []);
return <span {...attributes}>{children}</span>
}, [])
const handleKeyDown = useCallback(
(event: React.KeyboardEvent) => {
// Handle hotkeys for formatting
for (const hotkey in HOTKEYS) {
if (isHotkey(hotkey, event)) {
event.preventDefault();
const mark = HOTKEYS[hotkey];
event.preventDefault()
const mark = HOTKEYS[hotkey]
if (mark) {
toggleMark(editor, mark);
toggleMark(editor, mark)
}
return;
return
}
}
// Handle block formatting hotkeys
if (isHotkey("mod+shift+1", event)) {
event.preventDefault();
toggleBlock(editor, "heading", 1);
return;
event.preventDefault()
toggleBlock(editor, "heading", 1)
return
}
if (isHotkey("mod+shift+2", event)) {
event.preventDefault();
toggleBlock(editor, "heading", 2);
return;
event.preventDefault()
toggleBlock(editor, "heading", 2)
return
}
if (isHotkey("mod+shift+3", event)) {
event.preventDefault();
toggleBlock(editor, "heading", 3);
return;
event.preventDefault()
toggleBlock(editor, "heading", 3)
return
}
if (isHotkey("mod+shift+8", event)) {
event.preventDefault();
toggleBlock(editor, "list-item");
return;
event.preventDefault()
toggleBlock(editor, "list-item")
return
}
if (isHotkey("mod+shift+.", event)) {
event.preventDefault();
toggleBlock(editor, "block-quote");
return;
event.preventDefault()
toggleBlock(editor, "block-quote")
return
}
},
[editor],
);
)
const handleSlateChange = useCallback(
(newValue: Descendant[]) => {
setEditorValue(newValue);
const serializedValue = serialize(newValue);
onChange?.(serializedValue);
setEditorValue(newValue)
const serializedValue = serialize(newValue)
onChange?.(serializedValue)
},
[onChange],
);
)
// Memoized active states that update when selection changes
const activeStates = useMemo(
@ -387,7 +389,7 @@ export function TextEditor({
blockQuote: isBlockActive(editor, "block-quote"),
}),
[editor, selection],
);
)
const ToolbarButton = ({
icon: Icon,
@ -395,10 +397,10 @@ export function TextEditor({
onMouseDown,
title,
}: {
icon: React.ComponentType<{ className?: string }>;
isActive: boolean;
onMouseDown: (event: React.MouseEvent) => void;
title: string;
icon: React.ComponentType<{ className?: string }>
isActive: boolean
onMouseDown: (event: React.MouseEvent) => void
title: string
}) => (
<Button
variant="ghost"
@ -420,131 +422,136 @@ export function TextEditor({
)}
/>
</Button>
);
)
return (
<div className={cn("bg-foreground/5 border border-foreground/10 rounded-md", containerClassName)}>
<div
className={cn(
"bg-foreground/5 border border-foreground/10 rounded-md",
containerClassName,
)}
>
<div className={cn("flex flex-col", className)}>
<div className="flex-1 min-h-48 overflow-y-auto">
<Slate
editor={editor}
initialValue={editorValue}
onValueChange={handleSlateChange}
onSelectionChange={() => setSelection(editor.selection)}
>
<Editable
renderElement={renderElement}
renderLeaf={renderLeaf}
placeholder={placeholder}
renderPlaceholder={({ children, attributes }) => {
return (
<div {...attributes} className="mt-2">
{children}
</div>
);
}}
onKeyDown={handleKeyDown}
onBlur={onBlur}
readOnly={disabled}
className={cn(
"outline-none w-full h-full placeholder:text-foreground/50",
disabled && "opacity-50 cursor-not-allowed",
)}
style={{
minHeight: "23rem",
maxHeight: "23rem",
padding: "12px",
overflowX: "hidden",
}}
/>
</Slate>
</div>
{/* Toolbar */}
<div className="p-1 flex items-center gap-2 bg-foreground/5 backdrop-blur-sm rounded-b-md">
<div className="flex items-center gap-1">
{/* Text formatting */}
<ToolbarButton
icon={Bold}
isActive={activeStates.bold}
onMouseDown={(event) => {
event.preventDefault();
toggleMark(editor, "bold");
}}
title="Bold (Ctrl/Cmd+B)"
/>
<ToolbarButton
icon={Italic}
isActive={activeStates.italic}
onMouseDown={(event) => {
event.preventDefault();
toggleMark(editor, "italic");
}}
title="Italic (Ctrl/Cmd+I)"
/>
<ToolbarButton
icon={Code}
isActive={activeStates.code}
onMouseDown={(event) => {
event.preventDefault();
toggleMark(editor, "code");
}}
title="Code (Ctrl/Cmd+`)"
/>
<div className="flex-1 min-h-48 overflow-y-auto">
<Slate
editor={editor}
initialValue={editorValue}
onValueChange={handleSlateChange}
onSelectionChange={() => setSelection(editor.selection)}
>
<Editable
renderElement={renderElement}
renderLeaf={renderLeaf}
placeholder={placeholder}
renderPlaceholder={({ children, attributes }) => {
return (
<div {...attributes} className="mt-2">
{children}
</div>
)
}}
onKeyDown={handleKeyDown}
onBlur={onBlur}
readOnly={disabled}
className={cn(
"outline-none w-full h-full placeholder:text-foreground/50",
disabled && "opacity-50 cursor-not-allowed",
)}
style={{
minHeight: "23rem",
maxHeight: "23rem",
padding: "12px",
overflowX: "hidden",
}}
/>
</Slate>
</div>
<div className="w-px h-6 bg-foreground/30 mx-2" />
{/* Toolbar */}
<div className="p-1 flex items-center gap-2 bg-foreground/5 backdrop-blur-sm rounded-b-md">
<div className="flex items-center gap-1">
{/* Text formatting */}
<ToolbarButton
icon={Bold}
isActive={activeStates.bold}
onMouseDown={(event) => {
event.preventDefault()
toggleMark(editor, "bold")
}}
title="Bold (Ctrl/Cmd+B)"
/>
<ToolbarButton
icon={Italic}
isActive={activeStates.italic}
onMouseDown={(event) => {
event.preventDefault()
toggleMark(editor, "italic")
}}
title="Italic (Ctrl/Cmd+I)"
/>
<ToolbarButton
icon={Code}
isActive={activeStates.code}
onMouseDown={(event) => {
event.preventDefault()
toggleMark(editor, "code")
}}
title="Code (Ctrl/Cmd+`)"
/>
</div>
<div className="flex items-center gap-1">
{/* Block formatting */}
<ToolbarButton
icon={Heading1}
isActive={activeStates.heading1}
onMouseDown={(event) => {
event.preventDefault();
toggleBlock(editor, "heading", 1);
}}
title="Heading 1 (Ctrl/Cmd+Shift+1)"
/>
<ToolbarButton
icon={Heading2}
isActive={activeStates.heading2}
onMouseDown={(event) => {
event.preventDefault();
toggleBlock(editor, "heading", 2);
}}
title="Heading 2 (Ctrl/Cmd+Shift+2)"
/>
<ToolbarButton
icon={Heading3}
isActive={activeStates.heading3}
onMouseDown={(event) => {
event.preventDefault();
toggleBlock(editor, "heading", 3);
}}
title="Heading 3"
/>
<ToolbarButton
icon={List}
isActive={activeStates.listItem}
onMouseDown={(event) => {
event.preventDefault();
toggleBlock(editor, "list-item");
}}
title="Bullet List"
/>
<ToolbarButton
icon={Quote}
isActive={activeStates.blockQuote}
onMouseDown={(event) => {
event.preventDefault();
toggleBlock(editor, "block-quote");
}}
title="Quote"
/>
<div className="w-px h-6 bg-foreground/30 mx-2" />
<div className="flex items-center gap-1">
{/* Block formatting */}
<ToolbarButton
icon={Heading1}
isActive={activeStates.heading1}
onMouseDown={(event) => {
event.preventDefault()
toggleBlock(editor, "heading", 1)
}}
title="Heading 1 (Ctrl/Cmd+Shift+1)"
/>
<ToolbarButton
icon={Heading2}
isActive={activeStates.heading2}
onMouseDown={(event) => {
event.preventDefault()
toggleBlock(editor, "heading", 2)
}}
title="Heading 2 (Ctrl/Cmd+Shift+2)"
/>
<ToolbarButton
icon={Heading3}
isActive={activeStates.heading3}
onMouseDown={(event) => {
event.preventDefault()
toggleBlock(editor, "heading", 3)
}}
title="Heading 3"
/>
<ToolbarButton
icon={List}
isActive={activeStates.listItem}
onMouseDown={(event) => {
event.preventDefault()
toggleBlock(editor, "list-item")
}}
title="Bullet List"
/>
<ToolbarButton
icon={Quote}
isActive={activeStates.blockQuote}
onMouseDown={(event) => {
event.preventDefault()
toggleBlock(editor, "block-quote")
}}
title="Quote"
/>
</div>
</div>
</div>
</div>
</div>
);
)
}

View file

@ -12,7 +12,7 @@ import {
Copy,
RotateCcw,
X,
Square
Square,
} from "lucide-react"
import { useCallback, useEffect, useRef, useState } from "react"
import { toast } from "sonner"

View file

@ -111,7 +111,6 @@ export function ConnectionsTabContent() {
// Add connection mutation
const addConnectionMutation = useMutation({
mutationFn: async (provider: ConnectorProvider) => {
// Check if user can add connections
if (!canAddConnection && !isProUser) {
throw new Error(

View file

@ -1,7 +1,7 @@
"use client";
"use client"
import { $fetch } from "@lib/api";
import { Button } from "@repo/ui/components/button";
import { $fetch } from "@lib/api"
import { Button } from "@repo/ui/components/button"
import {
Dialog,
@ -10,57 +10,57 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@repo/ui/components/dialog";
} from "@repo/ui/components/dialog"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@repo/ui/components/dropdown-menu";
import { Input } from "@repo/ui/components/input";
import { Label } from "@repo/ui/components/label";
} from "@repo/ui/components/dropdown-menu"
import { Input } from "@repo/ui/components/input"
import { Label } from "@repo/ui/components/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@repo/ui/components/select";
import { Skeleton } from "@repo/ui/components/skeleton";
} from "@repo/ui/components/select"
import { Skeleton } from "@repo/ui/components/skeleton"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { FolderIcon, Loader2, MoreVertical, Plus, Trash2 } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { FolderIcon, Loader2, MoreVertical, Plus, Trash2 } from "lucide-react"
import { AnimatePresence, motion } from "motion/react"
import { useState } from "react";
import { toast } from "sonner";
import { useProject } from "@/stores";
import { useState } from "react"
import { toast } from "sonner"
import { useProject } from "@/stores"
// Projects View Component
export function ProjectsView() {
const queryClient = useQueryClient();
const { selectedProject, setSelectedProject } = useProject();
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [projectName, setProjectName] = useState("");
const queryClient = useQueryClient()
const { selectedProject, setSelectedProject } = useProject()
const [showCreateDialog, setShowCreateDialog] = useState(false)
const [projectName, setProjectName] = useState("")
const [deleteDialog, setDeleteDialog] = useState<{
open: boolean;
project: null | { id: string; name: string; containerTag: string };
action: "move" | "delete";
targetProjectId: string;
open: boolean
project: null | { id: string; name: string; containerTag: string }
action: "move" | "delete"
targetProjectId: string
}>({
open: false,
project: null,
action: "move",
targetProjectId: "",
});
})
const [expDialog, setExpDialog] = useState<{
open: boolean;
projectId: string;
open: boolean
projectId: string
}>({
open: false,
projectId: "",
});
})
// Fetch projects
const {
@ -70,42 +70,42 @@ export function ProjectsView() {
} = useQuery({
queryKey: ["projects"],
queryFn: async () => {
const response = await $fetch("@get/projects");
const response = await $fetch("@get/projects")
if (response.error) {
throw new Error(response.error?.message || "Failed to load projects");
throw new Error(response.error?.message || "Failed to load projects")
}
return response.data?.projects || [];
return response.data?.projects || []
},
staleTime: 30 * 1000,
});
})
// Create project mutation
const createProjectMutation = useMutation({
mutationFn: async (name: string) => {
const response = await $fetch("@post/projects", {
body: { name },
});
})
if (response.error) {
throw new Error(response.error?.message || "Failed to create project");
throw new Error(response.error?.message || "Failed to create project")
}
return response.data;
return response.data
},
onSuccess: () => {
toast.success("Project created successfully!");
setShowCreateDialog(false);
setProjectName("");
queryClient.invalidateQueries({ queryKey: ["projects"] });
toast.success("Project created successfully!")
setShowCreateDialog(false)
setProjectName("")
queryClient.invalidateQueries({ queryKey: ["projects"] })
},
onError: (error) => {
toast.error("Failed to create project", {
description: error instanceof Error ? error.message : "Unknown error",
});
})
},
});
})
// Delete project mutation
const deleteProjectMutation = useMutation({
@ -114,72 +114,72 @@ export function ProjectsView() {
action,
targetProjectId,
}: {
projectId: string;
action: "move" | "delete";
targetProjectId?: string;
projectId: string
action: "move" | "delete"
targetProjectId?: string
}) => {
const response = await $fetch(`@delete/projects/${projectId}`, {
body: { action, targetProjectId },
});
})
if (response.error) {
throw new Error(response.error?.message || "Failed to delete project");
throw new Error(response.error?.message || "Failed to delete project")
}
return response.data;
return response.data
},
onSuccess: () => {
toast.success("Project deleted successfully");
toast.success("Project deleted successfully")
setDeleteDialog({
open: false,
project: null,
action: "move",
targetProjectId: "",
});
queryClient.invalidateQueries({ queryKey: ["projects"] });
})
queryClient.invalidateQueries({ queryKey: ["projects"] })
// If we deleted the selected project, switch to default
if (deleteDialog.project?.containerTag === selectedProject) {
setSelectedProject("sm_project_default");
setSelectedProject("sm_project_default")
}
},
onError: (error) => {
toast.error("Failed to delete project", {
description: error instanceof Error ? error.message : "Unknown error",
});
})
},
});
})
// Enable experimental mode mutation
const enableExperimentalMutation = useMutation({
mutationFn: async (projectId: string) => {
const response = await $fetch(
`@post/projects/${projectId}/enable-experimental`,
);
)
if (response.error) {
throw new Error(
response.error?.message || "Failed to enable experimental mode",
);
)
}
return response.data;
return response.data
},
onSuccess: () => {
toast.success("Experimental mode enabled for project");
queryClient.invalidateQueries({ queryKey: ["projects"] });
setExpDialog({ open: false, projectId: "" });
toast.success("Experimental mode enabled for project")
queryClient.invalidateQueries({ queryKey: ["projects"] })
setExpDialog({ open: false, projectId: "" })
},
onError: (error) => {
toast.error("Failed to enable experimental mode", {
description: error instanceof Error ? error.message : "Unknown error",
});
})
},
});
})
// Handle project selection
const handleProjectSelect = (containerTag: string) => {
setSelectedProject(containerTag);
toast.success("Project switched successfully");
};
setSelectedProject(containerTag)
toast.success("Project switched successfully")
}
return (
<div className="space-y-4">
@ -344,11 +344,11 @@ export function ProjectsView() {
<DropdownMenuItem
className="text-blue-400 hover:text-blue-300 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
e.stopPropagation()
setExpDialog({
open: true,
projectId: project.id,
});
})
}}
>
<div className="h-4 w-4 mr-2 rounded border border-blue-400" />
@ -367,7 +367,7 @@ export function ProjectsView() {
<DropdownMenuItem
className="text-red-400 hover:text-red-300 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
e.stopPropagation()
setDeleteDialog({
open: true,
project: {
@ -377,7 +377,7 @@ export function ProjectsView() {
},
action: "move",
targetProjectId: "",
});
})
}}
>
<Trash2 className="h-4 w-4 mr-2" />
@ -436,8 +436,8 @@ export function ProjectsView() {
<Button
className="bg-white/5 hover:bg-white/10 border-white/10 text-white"
onClick={() => {
setShowCreateDialog(false);
setProjectName("");
setShowCreateDialog(false)
setProjectName("")
}}
type="button"
variant="outline"
@ -642,7 +642,7 @@ export function ProjectsView() {
deleteDialog.action === "move"
? deleteDialog.targetProjectId
: undefined,
});
})
}
}}
type="button"
@ -745,5 +745,5 @@ export function ProjectsView() {
)}
</AnimatePresence>
</div>
);
)
}

View file

@ -5,6 +5,7 @@ import { toast } from "sonner"
import { $fetch } from "@lib/api"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import type { z } from "zod"
import { analytics } from "@/lib/analytics"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
@ -29,7 +30,9 @@ interface UseDocumentMutationsOptions {
onClose?: () => void
}
export function useDocumentMutations({ onClose }: UseDocumentMutationsOptions = {}) {
export function useDocumentMutations({
onClose,
}: UseDocumentMutationsOptions = {}) {
const queryClient = useQueryClient()
const noteMutation = useMutation({
@ -111,6 +114,10 @@ export function useDocumentMutations({ onClose }: UseDocumentMutationsOptions =
})
},
onSuccess: (_data, variables) => {
analytics.documentAdded({
type: "note",
project_id: variables.project,
})
toast.success("Note added successfully!", {
description: "Your note is being processed",
})
@ -194,6 +201,10 @@ export function useDocumentMutations({ onClose }: UseDocumentMutationsOptions =
})
},
onSuccess: (_data, variables) => {
analytics.documentAdded({
type: "link",
project_id: variables.project,
})
toast.success("Link added successfully!", {
description: "Your link is being processed",
})
@ -311,6 +322,10 @@ export function useDocumentMutations({ onClose }: UseDocumentMutationsOptions =
})
},
onSuccess: (_data, variables) => {
analytics.documentAdded({
type: "file",
project_id: variables.project,
})
toast.success("File uploaded successfully!", {
description: "Your file is being processed",
})
@ -392,7 +407,8 @@ export function useDocumentMutations({ onClose }: UseDocumentMutationsOptions =
return {
...page,
documents: page.documents.filter(
(doc) => doc.id !== documentId && doc.customId !== documentId,
(doc) =>
doc.id !== documentId && doc.customId !== documentId,
),
pagination: page.pagination
? {
@ -412,11 +428,9 @@ export function useDocumentMutations({ onClose }: UseDocumentMutationsOptions =
const queryData = old as DocumentsQueryData
return {
...queryData,
documents: queryData.documents.filter(
(doc: DocumentWithId) => {
return doc.id !== documentId && doc.customId !== documentId
},
),
documents: queryData.documents.filter((doc: DocumentWithId) => {
return doc.id !== documentId && doc.customId !== documentId
}),
totalCount: Math.max(0, (queryData.totalCount ?? 0) - 1),
}
}

View file

@ -1,8 +1,8 @@
"use client";
"use client"
import { useQueryClient } from "@tanstack/react-query";
import { useMemo } from "react";
import { useProject } from "@/stores";
import { useQueryClient } from "@tanstack/react-query"
import { useMemo } from "react"
import { useProject } from "@/stores"
/**
* Returns the display name of the currently selected project.
@ -10,17 +10,17 @@ import { useProject } from "@/stores";
* hasn’t been fetched yet.
*/
export function useProjectName() {
const { selectedProject } = useProject();
const queryClient = useQueryClient();
const { selectedProject } = useProject()
const queryClient = useQueryClient()
// This query is populated by ProjectsView – we just read from the cache.
const projects = queryClient.getQueryData(["projects"]) as
| Array<{ name: string; containerTag: string }>
| undefined;
| undefined
return useMemo(() => {
if (selectedProject === "sm_project_default") return "Default Project";
const found = projects?.find((p) => p.containerTag === selectedProject);
return found?.name ?? selectedProject;
}, [projects, selectedProject]);
if (selectedProject === "sm_project_default") return "Default Project"
const found = projects?.find((p) => p.containerTag === selectedProject)
return found?.name ?? selectedProject
}, [projects, selectedProject])
}

View file

@ -1,23 +1,23 @@
import { useEffect, useState } from "react";
import { useEffect, useState } from "react"
export default function useResizeObserver<T extends HTMLElement>(
ref: React.RefObject<T | null>,
) {
const [size, setSize] = useState({ width: 0, height: 0 });
const [size, setSize] = useState({ width: 0, height: 0 })
useEffect(() => {
if (!ref.current) return;
if (!ref.current) return
const observer = new ResizeObserver(([entry]) => {
setSize({
width: entry?.contentRect.width ?? 0,
height: entry?.contentRect.height ?? 0,
});
});
})
})
observer.observe(ref.current);
return () => observer.disconnect();
}, [ref]);
observer.observe(ref.current)
return () => observer.disconnect()
}, [ref])
return size;
return size
}

View file

@ -2,29 +2,29 @@
// The added config here will be used whenever a users loads a page in their browser.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from '@sentry/nextjs';
import * as Sentry from "@sentry/nextjs"
Sentry.init({
dsn: 'https://2451ebfd1a7490f05fa7776482df81b6@o4508385422802944.ingest.us.sentry.io/4509872269819904',
dsn: "https://2451ebfd1a7490f05fa7776482df81b6@o4508385422802944.ingest.us.sentry.io/4509872269819904",
// Add optional integrations for additional features
integrations: [Sentry.replayIntegration()],
// Add optional integrations for additional features
integrations: [Sentry.replayIntegration()],
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
tracesSampleRate: 1,
// Enable logs to be sent to Sentry
enableLogs: true,
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
tracesSampleRate: 1,
// Enable logs to be sent to Sentry
enableLogs: true,
// Define how likely Replay events are sampled.
// This sets the sample rate to be 10%. You may want this to be 100% while
// in development and sample at a lower rate in production
replaysSessionSampleRate: 0.1,
// Define how likely Replay events are sampled.
// This sets the sample rate to be 10%. You may want this to be 100% while
// in development and sample at a lower rate in production
replaysSessionSampleRate: 0.1,
// Define how likely Replay events are sampled when an error occurs.
replaysOnErrorSampleRate: 1.0,
// Define how likely Replay events are sampled when an error occurs.
replaysOnErrorSampleRate: 1.0,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
});
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
})
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart

View file

@ -1,10 +1,10 @@
import posthog from "posthog-js"
// Helper function to safely capture events
const safeCapture = (eventName: string, properties?: Record<string, any>) => {
if (posthog.__loaded) {
posthog.capture(eventName, properties)
}
const safeCapture = (eventName: string, properties?: Record<string, unknown>) => {
if (posthog.__loaded) {
posthog.capture(eventName, properties)
}
}
export const analytics = {
@ -47,4 +47,16 @@ export const analytics = {
mcpInstallCmdCopied: () => safeCapture("mcp_install_cmd_copied"),
extensionInstallClicked: () => safeCapture("extension_install_clicked"),
// nova analytics
documentAdded: (props: {
type: "note" | "link" | "file" | "connect"
project_id?: string
}) => safeCapture("document_added", props),
newChatCreated: () => safeCapture("new_chat_created"),
mcpModalOpened: () => safeCapture("mcp_modal_opened"),
addDocumentModalOpened: () => safeCapture("add_document_modal_opened"),
}

View file

@ -1,32 +1,32 @@
"use client";
"use client"
import { createContext, type ReactNode, useContext, useState } from "react";
import { createContext, type ReactNode, useContext, useState } from "react"
type ActivePanel = "menu" | "chat" | null;
type ActivePanel = "menu" | "chat" | null
interface MobilePanelContextType {
activePanel: ActivePanel;
setActivePanel: (panel: ActivePanel) => void;
activePanel: ActivePanel
setActivePanel: (panel: ActivePanel) => void
}
const MobilePanelContext = createContext<MobilePanelContextType | undefined>(
undefined,
);
)
export function MobilePanelProvider({ children }: { children: ReactNode }) {
const [activePanel, setActivePanel] = useState<ActivePanel>(null);
const [activePanel, setActivePanel] = useState<ActivePanel>(null)
return (
<MobilePanelContext.Provider value={{ activePanel, setActivePanel }}>
{children}
</MobilePanelContext.Provider>
);
)
}
export function useMobilePanel() {
const context = useContext(MobilePanelContext);
const context = useContext(MobilePanelContext)
if (!context) {
throw new Error("useMobilePanel must be used within a MobilePanelProvider");
throw new Error("useMobilePanel must be used within a MobilePanelProvider")
}
return context;
return context
}

View file

@ -1,4 +1,4 @@
"use client";
"use client"
import {
createContext,
@ -6,73 +6,73 @@ import {
useContext,
useEffect,
useState,
} from "react";
import { analytics } from "@/lib/analytics";
} from "react"
import { analytics } from "@/lib/analytics"
type ViewMode = "graph" | "list";
type ViewMode = "graph" | "list"
interface ViewModeContextType {
viewMode: ViewMode;
setViewMode: (mode: ViewMode) => void;
isInitialized: boolean;
viewMode: ViewMode
setViewMode: (mode: ViewMode) => void
isInitialized: boolean
}
const ViewModeContext = createContext<ViewModeContextType | undefined>(
undefined,
);
)
// Cookie utility functions
const setCookie = (name: string, value: string, days = 365) => {
if (typeof document === "undefined") return;
const expires = new Date();
expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000);
document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/`;
};
if (typeof document === "undefined") return
const expires = new Date()
expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000)
document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/`
}
const getCookie = (name: string): string | null => {
if (typeof document === "undefined") return null;
const nameEQ = `${name}=`;
const ca = document.cookie.split(";");
if (typeof document === "undefined") return null
const nameEQ = `${name}=`
const ca = document.cookie.split(";")
for (let i = 0; i < ca.length; i++) {
let c = ca[i];
if (!c) continue;
while (c.charAt(0) === " ") c = c.substring(1, c.length);
if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length);
let c = ca[i]
if (!c) continue
while (c.charAt(0) === " ") c = c.substring(1, c.length)
if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length)
}
return null;
};
return null
}
const isMobileDevice = () => {
if (typeof window === "undefined") return false;
return window.innerWidth < 768;
};
if (typeof window === "undefined") return false
return window.innerWidth < 768
}
export function ViewModeProvider({ children }: { children: ReactNode }) {
// Start with a default that works for SSR
const [viewMode, setViewModeState] = useState<ViewMode>("graph");
const [isInitialized, setIsInitialized] = useState(false);
const [viewMode, setViewModeState] = useState<ViewMode>("graph")
const [isInitialized, setIsInitialized] = useState(false)
// Load preferences on the client side
useEffect(() => {
if (!isInitialized) {
// Check for saved preference first
const savedMode = getCookie("memoryViewMode");
const savedMode = getCookie("memoryViewMode")
if (savedMode === "list" || savedMode === "graph") {
setViewModeState(savedMode);
setViewModeState(savedMode)
} else {
// If no saved preference, default to list on mobile, graph on desktop
setViewModeState(isMobileDevice() ? "list" : "graph");
setViewModeState(isMobileDevice() ? "list" : "graph")
}
setIsInitialized(true);
setIsInitialized(true)
}
}, [isInitialized]);
}, [isInitialized])
// Save to cookie whenever view mode changes
const handleSetViewMode = (mode: ViewMode) => {
analytics.viewModeChanged(mode);
setViewModeState(mode);
setCookie("memoryViewMode", mode);
};
analytics.viewModeChanged(mode)
setViewModeState(mode)
setCookie("memoryViewMode", mode)
}
return (
<ViewModeContext.Provider
@ -84,13 +84,13 @@ export function ViewModeProvider({ children }: { children: ReactNode }) {
>
{children}
</ViewModeContext.Provider>
);
)
}
export function useViewMode() {
const context = useContext(ViewModeContext);
const context = useContext(ViewModeContext)
if (!context) {
throw new Error("useViewMode must be used within a ViewModeProvider");
throw new Error("useViewMode must be used within a ViewModeProvider")
}
return context;
return context
}

View file

@ -4,7 +4,7 @@ import { NextResponse } from "next/server"
export default async function proxy(request: Request) {
console.debug("[PROXY] === PROXY START ===")
const url = new URL(request.url)
console.debug("[PROXY] Path:", url.pathname)
console.debug("[PROXY] Method:", request.method)

View file

@ -1,6 +1,6 @@
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache";
import { defineCloudflareConfig } from "@opennextjs/cloudflare"
import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache"
export default defineCloudflareConfig({
incrementalCache: r2IncrementalCache,
});
})

View file

@ -1,5 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
}
export default config;
export default config

View file

@ -7,7 +7,10 @@ import { indexedDBStorage } from "./indexeddb-storage"
/**
* Deep equality check for UIMessage arrays to prevent unnecessary state updates
*/
export function areUIMessageArraysEqual(a: UIMessage[], b: UIMessage[]): boolean {
export function areUIMessageArraysEqual(
a: UIMessage[],
b: UIMessage[],
): boolean {
if (a === b) return true
if (a.length !== b.length) return false

View file

@ -1,10 +1,10 @@
import { create } from "zustand";
import { create } from "zustand"
interface GraphHighlightsState {
documentIds: string[];
lastUpdated: number;
setDocumentIds: (ids: string[]) => void;
clear: () => void;
documentIds: string[]
lastUpdated: number
setDocumentIds: (ids: string[]) => void
clear: () => void
}
export const useGraphHighlightsStore = create<GraphHighlightsState>()(
@ -12,24 +12,24 @@ export const useGraphHighlightsStore = create<GraphHighlightsState>()(
documentIds: [],
lastUpdated: 0,
setDocumentIds: (ids) => {
const next = Array.from(new Set(ids));
const prev = get().documentIds;
const next = Array.from(new Set(ids))
const prev = get().documentIds
if (
prev.length === next.length &&
prev.every((id) => next.includes(id))
) {
return;
return
}
set({ documentIds: next, lastUpdated: Date.now() });
set({ documentIds: next, lastUpdated: Date.now() })
},
clear: () => set({ documentIds: [], lastUpdated: Date.now() }),
}),
);
)
export function useGraphHighlights() {
const documentIds = useGraphHighlightsStore((s) => s.documentIds);
const lastUpdated = useGraphHighlightsStore((s) => s.lastUpdated);
const setDocumentIds = useGraphHighlightsStore((s) => s.setDocumentIds);
const clear = useGraphHighlightsStore((s) => s.clear);
return { documentIds, lastUpdated, setDocumentIds, clear };
const documentIds = useGraphHighlightsStore((s) => s.documentIds)
const lastUpdated = useGraphHighlightsStore((s) => s.lastUpdated)
const setDocumentIds = useGraphHighlightsStore((s) => s.setDocumentIds)
const clear = useGraphHighlightsStore((s) => s.clear)
return { documentIds, lastUpdated, setDocumentIds, clear }
}

View file

@ -1,24 +1,24 @@
import { get, set, del } from 'idb-keyval';
import { get, set, del } from "idb-keyval"
export const indexedDBStorage = {
getItem: async (name: string) => {
let value = await get(name);
if (value !== undefined) {
return value;
}
// Migrate from localStorage if exists
value = localStorage.getItem(name);
if (value !== null) {
await set(name, value);
localStorage.removeItem(name);
return value;
}
return null;
},
setItem: async (name: string, value: string) => {
await set(name, value);
},
removeItem: async (name: string) => {
await del(name);
},
};
getItem: async (name: string) => {
let value = await get(name)
if (value !== undefined) {
return value
}
// Migrate from localStorage if exists
value = localStorage.getItem(name)
if (value !== null) {
await set(name, value)
localStorage.removeItem(name)
return value
}
return null
},
setItem: async (name: string, value: string) => {
await set(name, value)
},
removeItem: async (name: string) => {
await del(name)
},
}

View file

@ -1,20 +1,20 @@
{
"compilerOptions": {
"incremental": true,
"jsx": "preserve",
"paths": {
"@/*": ["./*"],
"@ui/*": ["../../packages/ui/*"],
"@lib/*": ["../../packages/lib/*"],
"@hooks/*": ["../../packages/hooks/*"]
},
"plugins": [
{
"name": "next"
}
]
},
"exclude": ["node_modules"],
"extends": "@total-typescript/tsconfig/bundler/dom/app",
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"]
"compilerOptions": {
"incremental": true,
"jsx": "preserve",
"paths": {
"@/*": ["./*"],
"@ui/*": ["../../packages/ui/*"],
"@lib/*": ["../../packages/lib/*"],
"@hooks/*": ["../../packages/hooks/*"]
},
"plugins": [
{
"name": "next"
}
]
},
"exclude": ["node_modules"],
"extends": "@total-typescript/tsconfig/bundler/dom/app",
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"]
}

View file

@ -1,31 +1,31 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"main": ".open-next/worker.js",
"name": "supermemory-app",
"compatibility_date": "2024-12-30",
"compatibility_flags": [
// Enable Node.js API
// see https://developers.cloudflare.com/workers/configuration/compatibility-flags/#nodejs-compatibility-flag
"nodejs_compat",
// Allow to fetch URLs in your app
// see https://developers.cloudflare.com/workers/configuration/compatibility-flags/#global-fetch-strictly-public
"global_fetch_strictly_public",
],
"assets": {
"directory": ".open-next/assets",
"binding": "ASSETS",
},
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
// The service should match the "name" of your worker
"service": "supermemory-app",
},
],
"r2_buckets": [
{
"binding": "NEXT_INC_CACHE_R2_BUCKET",
"bucket_name": "supermemory-console-cache",
},
],
"$schema": "node_modules/wrangler/config-schema.json",
"main": ".open-next/worker.js",
"name": "supermemory-app",
"compatibility_date": "2024-12-30",
"compatibility_flags": [
// Enable Node.js API
// see https://developers.cloudflare.com/workers/configuration/compatibility-flags/#nodejs-compatibility-flag
"nodejs_compat",
// Allow to fetch URLs in your app
// see https://developers.cloudflare.com/workers/configuration/compatibility-flags/#global-fetch-strictly-public
"global_fetch_strictly_public"
],
"assets": {
"directory": ".open-next/assets",
"binding": "ASSETS"
},
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
// The service should match the "name" of your worker
"service": "supermemory-app"
}
],
"r2_buckets": [
{
"binding": "NEXT_INC_CACHE_R2_BUCKET",
"bucket_name": "supermemory-console-cache"
}
]
}

View file

@ -8,114 +8,123 @@ const filter = args[0] // e.g., "typescript", "python", "integrations", or speci
const TESTS_DIR = path.join(import.meta.dir, "tests")
interface TestFile {
name: string
path: string
type: "ts" | "py"
name: string
path: string
type: "ts" | "py"
}
function getTests(): TestFile[] {
const tests: TestFile[] = []
const tests: TestFile[] = []
// TypeScript tests
const tsTests = ["quickstart", "sdk", "search", "user-profiles"]
for (const t of tsTests) {
tests.push({
name: `typescript/${t}`,
path: path.join(TESTS_DIR, "typescript", `${t}.ts`),
type: "ts",
})
}
// TypeScript tests
const tsTests = ["quickstart", "sdk", "search", "user-profiles"]
for (const t of tsTests) {
tests.push({
name: `typescript/${t}`,
path: path.join(TESTS_DIR, "typescript", `${t}.ts`),
type: "ts",
})
}
// Python tests
const pyTests = ["quickstart", "sdk", "search", "user_profiles"]
for (const t of pyTests) {
tests.push({
name: `python/${t}`,
path: path.join(TESTS_DIR, "python", `${t}.py`),
type: "py",
})
}
// Python tests
const pyTests = ["quickstart", "sdk", "search", "user_profiles"]
for (const t of pyTests) {
tests.push({
name: `python/${t}`,
path: path.join(TESTS_DIR, "python", `${t}.py`),
type: "py",
})
}
// Integration tests
const intTests = [
{ name: "ai-sdk", type: "ts" as const },
{ name: "openai-sdk", type: "ts" as const },
{ name: "openai-sdk", type: "py" as const },
{ name: "claude-memory", type: "ts" as const },
]
for (const t of intTests) {
tests.push({
name: `integrations/${t.name}`,
path: path.join(TESTS_DIR, "integrations", `${t.name}.${t.type === "ts" ? "ts" : "py"}`),
type: t.type,
})
}
// Integration tests
const intTests = [
{ name: "ai-sdk", type: "ts" as const },
{ name: "openai-sdk", type: "ts" as const },
{ name: "openai-sdk", type: "py" as const },
{ name: "claude-memory", type: "ts" as const },
]
for (const t of intTests) {
tests.push({
name: `integrations/${t.name}`,
path: path.join(
TESTS_DIR,
"integrations",
`${t.name}.${t.type === "ts" ? "ts" : "py"}`,
),
type: t.type,
})
}
return tests
return tests
}
async function runTest(test: TestFile): Promise<boolean> {
return new Promise((resolve) => {
console.log(`\n${"=".repeat(60)}`)
console.log(`Running: ${test.name}`)
console.log("=".repeat(60))
return new Promise((resolve) => {
console.log(`\n${"=".repeat(60)}`)
console.log(`Running: ${test.name}`)
console.log("=".repeat(60))
const cmd = test.type === "ts" ? "bun" : path.join(import.meta.dir, ".venv", "bin", "python3")
const proc = spawn(cmd, [test.path], {
stdio: "inherit",
env: { ...process.env },
})
const cmd =
test.type === "ts"
? "bun"
: path.join(import.meta.dir, ".venv", "bin", "python3")
const proc = spawn(cmd, [test.path], {
stdio: "inherit",
env: { ...process.env },
})
proc.on("close", (code) => {
resolve(code === 0)
})
})
proc.on("close", (code) => {
resolve(code === 0)
})
})
}
async function main() {
console.log("Supermemory Docs Test Runner")
console.log("============================\n")
console.log("Supermemory Docs Test Runner")
console.log("============================\n")
let tests = getTests()
let tests = getTests()
// Filter tests if specified
if (filter) {
tests = tests.filter((t) => t.name.includes(filter) || t.type === filter.replace(".", ""))
}
// Filter tests if specified
if (filter) {
tests = tests.filter(
(t) => t.name.includes(filter) || t.type === filter.replace(".", ""),
)
}
if (tests.length === 0) {
console.log("No tests matched the filter:", filter)
console.log("\nAvailable tests:")
getTests().forEach((t) => console.log(` - ${t.name} (${t.type})`))
process.exit(1)
}
if (tests.length === 0) {
console.log("No tests matched the filter:", filter)
console.log("\nAvailable tests:")
getTests().forEach((t) => console.log(` - ${t.name} (${t.type})`))
process.exit(1)
}
console.log(`Running ${tests.length} test(s)...\n`)
console.log(`Running ${tests.length} test(s)...\n`)
const results: { test: string; passed: boolean }[] = []
const results: { test: string; passed: boolean }[] = []
for (const test of tests) {
const passed = await runTest(test)
results.push({ test: test.name, passed })
}
for (const test of tests) {
const passed = await runTest(test)
results.push({ test: test.name, passed })
}
// Summary
console.log(`\n${"=".repeat(60)}`)
console.log("SUMMARY")
console.log("=".repeat(60))
// Summary
console.log(`\n${"=".repeat(60)}`)
console.log("SUMMARY")
console.log("=".repeat(60))
const passed = results.filter((r) => r.passed).length
const failed = results.filter((r) => !r.passed).length
const passed = results.filter((r) => r.passed).length
const failed = results.filter((r) => !r.passed).length
for (const r of results) {
console.log(`${r.passed ? "✅" : "❌"} ${r.test}`)
}
for (const r of results) {
console.log(`${r.passed ? "✅" : "❌"} ${r.test}`)
}
console.log(`\nTotal: ${passed} passed, ${failed} failed`)
console.log(`\nTotal: ${passed} passed, ${failed} failed`)
if (failed > 0) {
process.exit(1)
}
if (failed > 0) {
process.exit(1)
}
}
main().catch(console.error)

View file

@ -2,99 +2,109 @@ import "dotenv/config"
import { openai } from "@ai-sdk/openai"
import { createAnthropic } from "@ai-sdk/anthropic"
import {
withSupermemory,
supermemoryTools,
searchMemoriesTool,
addMemoryTool,
type MemoryPromptData,
withSupermemory,
supermemoryTools,
searchMemoriesTool,
addMemoryTool,
type MemoryPromptData,
} from "@supermemory/tools/ai-sdk"
async function testMiddleware() {
console.log("=== Middleware ===")
console.log("=== Middleware ===")
// Basic wrapper
const model = withSupermemory(openai("gpt-4"), "user-123")
console.log("✓ withSupermemory basic")
// Basic wrapper
const model = withSupermemory(openai("gpt-4"), "user-123")
console.log("✓ withSupermemory basic")
// With addMemory option
const modelWithAdd = withSupermemory(openai("gpt-4"), "user-123", {
addMemory: "always",
})
console.log("✓ withSupermemory with addMemory")
// With addMemory option
const modelWithAdd = withSupermemory(openai("gpt-4"), "user-123", {
addMemory: "always",
})
console.log("✓ withSupermemory with addMemory")
// With verbose logging
const modelVerbose = withSupermemory(openai("gpt-4"), "user-123", {
verbose: true,
})
console.log("✓ withSupermemory with verbose")
// With verbose logging
const modelVerbose = withSupermemory(openai("gpt-4"), "user-123", {
verbose: true,
})
console.log("✓ withSupermemory with verbose")
}
async function testSearchModes() {
console.log("\n=== Search Modes ===")
console.log("\n=== Search Modes ===")
const profileModel = withSupermemory(openai("gpt-4"), "user-123", { mode: "profile" })
console.log("✓ mode: profile")
const profileModel = withSupermemory(openai("gpt-4"), "user-123", {
mode: "profile",
})
console.log("✓ mode: profile")
const queryModel = withSupermemory(openai("gpt-4"), "user-123", { mode: "query" })
console.log("✓ mode: query")
const queryModel = withSupermemory(openai("gpt-4"), "user-123", {
mode: "query",
})
console.log("✓ mode: query")
const fullModel = withSupermemory(openai("gpt-4"), "user-123", { mode: "full" })
console.log("✓ mode: full")
const fullModel = withSupermemory(openai("gpt-4"), "user-123", {
mode: "full",
})
console.log("✓ mode: full")
}
async function testCustomPrompt() {
console.log("\n=== Custom Prompt Template ===")
console.log("\n=== Custom Prompt Template ===")
const anthropic = createAnthropic({ apiKey: "test-key" })
const anthropic = createAnthropic({ apiKey: "test-key" })
const claudePrompt = (data: MemoryPromptData) =>
`
const claudePrompt = (data: MemoryPromptData) =>
`
<context>
<user_profile>${data.userMemories}</user_profile>
<relevant_memories>${data.generalSearchMemories}</relevant_memories>
</context>
`.trim()
const model = withSupermemory(anthropic("claude-3-sonnet-20240229"), "user-123", {
mode: "full",
promptTemplate: claudePrompt,
})
console.log("✓ Custom prompt template")
const model = withSupermemory(
anthropic("claude-3-sonnet-20240229"),
"user-123",
{
mode: "full",
promptTemplate: claudePrompt,
},
)
console.log("✓ Custom prompt template")
}
async function testTools() {
console.log("\n=== Memory Tools ===")
console.log("\n=== Memory Tools ===")
// All tools
const tools = supermemoryTools("YOUR_API_KEY")
console.log("✓ supermemoryTools")
// All tools
const tools = supermemoryTools("YOUR_API_KEY")
console.log("✓ supermemoryTools")
// Individual tools
const searchTool = searchMemoriesTool("API_KEY", { projectId: "personal" })
console.log("✓ searchMemoriesTool")
// Individual tools
const searchTool = searchMemoriesTool("API_KEY", { projectId: "personal" })
console.log("✓ searchMemoriesTool")
const addTool = addMemoryTool("API_KEY")
console.log("✓ addMemoryTool")
const addTool = addMemoryTool("API_KEY")
console.log("✓ addMemoryTool")
// Combined
const toolsObj = {
searchMemories: searchTool,
addMemory: addTool,
}
console.log("✓ Combined tools object")
// Combined
const toolsObj = {
searchMemories: searchTool,
addMemory: addTool,
}
console.log("✓ Combined tools object")
}
async function main() {
console.log("AI SDK Integration Tests")
console.log("========================\n")
console.log("AI SDK Integration Tests")
console.log("========================\n")
await testMiddleware()
await testSearchModes()
await testCustomPrompt()
await testTools()
await testMiddleware()
await testSearchModes()
await testCustomPrompt()
await testTools()
console.log("\n========================")
console.log("✅ All AI SDK tests passed!")
console.log("\n========================")
console.log("✅ All AI SDK tests passed!")
}
main().catch(console.error)

View file

@ -2,100 +2,104 @@ import "dotenv/config"
import { createClaudeMemoryTool } from "@supermemory/tools/claude-memory"
async function testConfiguration() {
console.log("=== Configuration ===")
console.log("=== Configuration ===")
// Basic
const tool = createClaudeMemoryTool(process.env.SUPERMEMORY_API_KEY!, {
projectId: "my-app",
})
console.log("✓ Basic config")
// Basic
const tool = createClaudeMemoryTool(process.env.SUPERMEMORY_API_KEY!, {
projectId: "my-app",
})
console.log("✓ Basic config")
// Full options
const toolFull = createClaudeMemoryTool(process.env.SUPERMEMORY_API_KEY!, {
projectId: "my-app",
containerTags: ["user-123", "project-alpha"],
memoryContainerTag: "my_memory_prefix",
})
console.log("✓ Full config")
// Full options
const toolFull = createClaudeMemoryTool(process.env.SUPERMEMORY_API_KEY!, {
projectId: "my-app",
containerTags: ["user-123", "project-alpha"],
memoryContainerTag: "my_memory_prefix",
})
console.log("✓ Full config")
return tool
return tool
}
async function testMethods(tool: ReturnType<typeof createClaudeMemoryTool>) {
console.log("\n=== Methods ===")
console.log("\n=== Methods ===")
console.log(`✓ handleCommand exists: ${typeof tool.handleCommand === "function"}`)
console.log(
`✓ handleCommandForToolResult exists: ${typeof tool.handleCommandForToolResult === "function"}`
)
console.log(
`✓ handleCommand exists: ${typeof tool.handleCommand === "function"}`,
)
console.log(
`✓ handleCommandForToolResult exists: ${typeof tool.handleCommandForToolResult === "function"}`,
)
}
async function testCommands(tool: ReturnType<typeof createClaudeMemoryTool>) {
console.log("\n=== Commands ===")
console.log("\n=== Commands ===")
// View (list)
const listResult = await tool.handleCommand({
command: "view",
path: "/memories/",
})
console.log(`✓ view (list): ${listResult.success}`)
// View (list)
const listResult = await tool.handleCommand({
command: "view",
path: "/memories/",
})
console.log(`✓ view (list): ${listResult.success}`)
// Create
const createResult = await tool.handleCommand({
command: "create",
path: "/memories/test-file.txt",
file_text: "User prefers dark mode\nFavorite language: TypeScript",
})
console.log(`✓ create: ${createResult.success}`)
// Create
const createResult = await tool.handleCommand({
command: "create",
path: "/memories/test-file.txt",
file_text: "User prefers dark mode\nFavorite language: TypeScript",
})
console.log(`✓ create: ${createResult.success}`)
// Wait for indexing
await new Promise((r) => setTimeout(r, 3000))
// Wait for indexing
await new Promise((r) => setTimeout(r, 3000))
// str_replace
const replaceResult = await tool.handleCommand({
command: "str_replace",
path: "/memories/test-file.txt",
old_str: "dark mode",
new_str: "light mode",
})
console.log(`✓ str_replace: ${replaceResult.success}`)
// str_replace
const replaceResult = await tool.handleCommand({
command: "str_replace",
path: "/memories/test-file.txt",
old_str: "dark mode",
new_str: "light mode",
})
console.log(`✓ str_replace: ${replaceResult.success}`)
// Delete
const deleteResult = await tool.handleCommand({
command: "delete",
path: "/memories/test-file.txt",
})
console.log(`✓ delete: ${deleteResult.success}`)
// Delete
const deleteResult = await tool.handleCommand({
command: "delete",
path: "/memories/test-file.txt",
})
console.log(`✓ delete: ${deleteResult.success}`)
}
async function testToolResultFormat(tool: ReturnType<typeof createClaudeMemoryTool>) {
console.log("\n=== Tool Result Format ===")
async function testToolResultFormat(
tool: ReturnType<typeof createClaudeMemoryTool>,
) {
console.log("\n=== Tool Result Format ===")
const toolResult = await tool.handleCommandForToolResult(
{ command: "view", path: "/memories/" },
"test-tool-id-123"
)
const toolResult = await tool.handleCommandForToolResult(
{ command: "view", path: "/memories/" },
"test-tool-id-123",
)
const hasCorrectStructure =
toolResult.type === "tool_result" &&
toolResult.tool_use_id === "test-tool-id-123" &&
typeof toolResult.content === "string" &&
typeof toolResult.is_error === "boolean"
const hasCorrectStructure =
toolResult.type === "tool_result" &&
toolResult.tool_use_id === "test-tool-id-123" &&
typeof toolResult.content === "string" &&
typeof toolResult.is_error === "boolean"
console.log(`✓ Tool result structure: ${hasCorrectStructure}`)
console.log(`✓ Tool result structure: ${hasCorrectStructure}`)
}
async function main() {
console.log("Claude Memory Tool Tests")
console.log("========================\n")
console.log("Claude Memory Tool Tests")
console.log("========================\n")
const tool = await testConfiguration()
await testMethods(tool)
await testCommands(tool)
await testToolResultFormat(tool)
const tool = await testConfiguration()
await testMethods(tool)
await testCommands(tool)
await testToolResultFormat(tool)
console.log("\n========================")
console.log("✅ All Claude Memory tests passed!")
console.log("\n========================")
console.log("✅ All Claude Memory tests passed!")
}
main().catch(console.error)

View file

@ -5,76 +5,82 @@ import { withSupermemory } from "@supermemory/tools/openai"
const OPENAI_API_KEY = process.env.OPENAI_API_KEY
async function testOpenAIWrapper() {
console.log("=== OpenAI SDK Wrapper ===")
console.log("=== OpenAI SDK Wrapper ===")
if (!OPENAI_API_KEY) {
console.log("⚠ OPENAI_API_KEY not set, skipping live tests")
return false
}
if (!OPENAI_API_KEY) {
console.log("⚠ OPENAI_API_KEY not set, skipping live tests")
return false
}
const openai = new OpenAI()
const openai = new OpenAI()
// Basic wrapper
const client = withSupermemory(openai, "docs-test-openai")
console.log("✓ withSupermemory basic")
// Basic wrapper
const client = withSupermemory(openai, "docs-test-openai")
console.log("✓ withSupermemory basic")
// With options
const clientWithOptions = withSupermemory(openai, "docs-test-openai", {
mode: "full",
addMemory: "always",
verbose: true,
})
console.log("✓ withSupermemory with options")
// With options
const clientWithOptions = withSupermemory(openai, "docs-test-openai", {
mode: "full",
addMemory: "always",
verbose: true,
})
console.log("✓ withSupermemory with options")
}
async function testChatCompletion() {
console.log("\n=== Chat Completion with Memory ===")
console.log("\n=== Chat Completion with Memory ===")
if (!OPENAI_API_KEY) {
console.log("⚠ OPENAI_API_KEY not set, skipping")
return
}
if (!OPENAI_API_KEY) {
console.log("⚠ OPENAI_API_KEY not set, skipping")
return
}
const openai = new OpenAI()
const client = withSupermemory(openai, "docs-test-openai", {
mode: "full",
addMemory: "always",
})
const openai = new OpenAI()
const client = withSupermemory(openai, "docs-test-openai", {
mode: "full",
addMemory: "always",
})
// Add context
const addResponse = await client.chat.completions.create({
model: "gpt-4o-mini",
messages: [
{ role: "system", content: "You are a helpful assistant." },
{ role: "user", content: "Remember that my favorite color is blue." },
],
max_tokens: 100,
})
console.log("✓ Add context:", addResponse.choices[0]?.message.content?.substring(0, 50))
// Add context
const addResponse = await client.chat.completions.create({
model: "gpt-4o-mini",
messages: [
{ role: "system", content: "You are a helpful assistant." },
{ role: "user", content: "Remember that my favorite color is blue." },
],
max_tokens: 100,
})
console.log(
"✓ Add context:",
addResponse.choices[0]?.message.content?.substring(0, 50),
)
// Retrieve context
const retrieveResponse = await client.chat.completions.create({
model: "gpt-4o-mini",
messages: [
{ role: "system", content: "You are a helpful assistant." },
{ role: "user", content: "What do you know about my preferences?" },
],
max_tokens: 100,
})
console.log("✓ Retrieve context:", retrieveResponse.choices[0]?.message.content?.substring(0, 50))
// Retrieve context
const retrieveResponse = await client.chat.completions.create({
model: "gpt-4o-mini",
messages: [
{ role: "system", content: "You are a helpful assistant." },
{ role: "user", content: "What do you know about my preferences?" },
],
max_tokens: 100,
})
console.log(
"✓ Retrieve context:",
retrieveResponse.choices[0]?.message.content?.substring(0, 50),
)
}
async function main() {
console.log("OpenAI SDK Integration Tests")
console.log("============================\n")
console.log("OpenAI SDK Integration Tests")
console.log("============================\n")
const hasKey = await testOpenAIWrapper()
if (hasKey !== false) {
await testChatCompletion()
}
const hasKey = await testOpenAIWrapper()
if (hasKey !== false) {
await testChatCompletion()
}
console.log("\n============================")
console.log("✅ All OpenAI SDK tests passed!")
console.log("\n============================")
console.log("✅ All OpenAI SDK tests passed!")
}
main().catch(console.error)

View file

@ -5,142 +5,147 @@ import Supermemory, { toFile } from "supermemory"
const client = new Supermemory()
async function testDocumentsCRUD() {
console.log("\n=== Document CRUD Operations ===")
console.log("\n=== Document CRUD Operations ===")
// Create
const doc = await client.documents.add({
content: "Test content for TypeScript SDK - " + Date.now(),
})
console.log("✓ documents.add:", doc.id)
// Create
const doc = await client.documents.add({
content: "Test content for TypeScript SDK - " + Date.now(),
})
console.log("✓ documents.add:", doc.id)
// Read
const fetched = await client.documents.get(doc.id)
console.log("✓ documents.get:", fetched.id)
// Read
const fetched = await client.documents.get(doc.id)
console.log("✓ documents.get:", fetched.id)
// Update
const updated = await client.documents.update(doc.id, {
content: "Updated content - " + Date.now(),
})
console.log("✓ documents.update:", updated.id)
// Update
const updated = await client.documents.update(doc.id, {
content: "Updated content - " + Date.now(),
})
console.log("✓ documents.update:", updated.id)
// Wait for processing before delete
await new Promise((r) => setTimeout(r, 10000))
// Wait for processing before delete
await new Promise((r) => setTimeout(r, 10000))
// Delete
await client.documents.delete(doc.id)
console.log("✓ documents.delete")
// Delete
await client.documents.delete(doc.id)
console.log("✓ documents.delete")
}
async function testBatchOperations() {
console.log("\n=== Batch Operations ===")
console.log("\n=== Batch Operations ===")
const batch = await client.documents.batchAdd({
documents: [
{ content: "Batch doc 1 - " + Date.now() },
{ content: "Batch doc 2 - " + Date.now() },
],
})
console.log("✓ documents.batchAdd:", batch)
const batch = await client.documents.batchAdd({
documents: [
{ content: "Batch doc 1 - " + Date.now() },
{ content: "Batch doc 2 - " + Date.now() },
],
})
console.log("✓ documents.batchAdd:", batch)
}
async function testSearch() {
console.log("\n=== Search ===")
console.log("\n=== Search ===")
const results = await client.search.execute({ q: "test content" })
console.log("✓ search.execute:", results.results?.length ?? 0, "results")
const results = await client.search.execute({ q: "test content" })
console.log("✓ search.execute:", results.results?.length ?? 0, "results")
}
async function testFileUploads() {
console.log("\n=== File Uploads ===")
console.log("\n=== File Uploads ===")
// Using fs.createReadStream
const testFile = "/tmp/test-doc-upload.txt"
fs.writeFileSync(testFile, "Test file content")
await client.documents.uploadFile({ file: fs.createReadStream(testFile) })
console.log("✓ uploadFile with fs.createReadStream")
// Using fs.createReadStream
const testFile = "/tmp/test-doc-upload.txt"
fs.writeFileSync(testFile, "Test file content")
await client.documents.uploadFile({ file: fs.createReadStream(testFile) })
console.log("✓ uploadFile with fs.createReadStream")
// Using File API
await client.documents.uploadFile({ file: new File(["my bytes"], "file") })
console.log("✓ uploadFile with File API")
// Using File API
await client.documents.uploadFile({ file: new File(["my bytes"], "file") })
console.log("✓ uploadFile with File API")
// Using toFile
await client.documents.uploadFile({ file: await toFile(Buffer.from("my bytes"), "file") })
console.log("✓ uploadFile with toFile")
// Using toFile
await client.documents.uploadFile({
file: await toFile(Buffer.from("my bytes"), "file"),
})
console.log("✓ uploadFile with toFile")
fs.unlinkSync(testFile)
fs.unlinkSync(testFile)
}
async function testClientConfig() {
console.log("\n=== Client Configuration ===")
console.log("\n=== Client Configuration ===")
// Retries
const clientWithRetries = new Supermemory({ maxRetries: 0 })
console.log("✓ maxRetries config")
// Retries
const clientWithRetries = new Supermemory({ maxRetries: 0 })
console.log("✓ maxRetries config")
// Timeout
const clientWithTimeout = new Supermemory({ timeout: 20 * 1000 })
console.log("✓ timeout config")
// Timeout
const clientWithTimeout = new Supermemory({ timeout: 20 * 1000 })
console.log("✓ timeout config")
// Per-request options
await client.add({ content: "Test - " + Date.now() }, { maxRetries: 5, timeout: 5000 })
console.log("✓ per-request options")
// Per-request options
await client.add(
{ content: "Test - " + Date.now() },
{ maxRetries: 5, timeout: 5000 },
)
console.log("✓ per-request options")
}
async function testRawResponse() {
console.log("\n=== Raw Response ===")
console.log("\n=== Raw Response ===")
const response = await client.documents
.add({ content: "Test - " + Date.now() })
.asResponse()
console.log("✓ .asResponse() status:", response.status)
const response = await client.documents
.add({ content: "Test - " + Date.now() })
.asResponse()
console.log("✓ .asResponse() status:", response.status)
const { data, response: raw } = await client.documents
.add({ content: "Test - " + Date.now() })
.withResponse()
console.log("✓ .withResponse() data:", data.id, "status:", raw.status)
const { data, response: raw } = await client.documents
.add({ content: "Test - " + Date.now() })
.withResponse()
console.log("✓ .withResponse() data:", data.id, "status:", raw.status)
}
async function testTypes() {
console.log("\n=== Types ===")
console.log("\n=== Types ===")
const params: Supermemory.AddParams = {
content: "Typed content",
}
const response: Supermemory.AddResponse = await client.add(params)
console.log("✓ TypeScript types work:", response.id)
const params: Supermemory.AddParams = {
content: "Typed content",
}
const response: Supermemory.AddResponse = await client.add(params)
console.log("✓ TypeScript types work:", response.id)
}
async function testErrorHandling() {
console.log("\n=== Error Handling ===")
console.log("\n=== Error Handling ===")
const response = await client.documents
.add({ content: "Test - " + Date.now() })
.catch(async (err) => {
if (err instanceof Supermemory.APIError) {
console.log("Caught APIError:", err.status, err.name)
} else {
throw err
}
return null
})
console.log("✓ Error handling pattern works:", response?.id)
const response = await client.documents
.add({ content: "Test - " + Date.now() })
.catch(async (err) => {
if (err instanceof Supermemory.APIError) {
console.log("Caught APIError:", err.status, err.name)
} else {
throw err
}
return null
})
console.log("✓ Error handling pattern works:", response?.id)
}
async function main() {
console.log("TypeScript SDK Tests")
console.log("====================")
console.log("TypeScript SDK Tests")
console.log("====================")
await testDocumentsCRUD()
await testBatchOperations()
await testSearch()
await testFileUploads()
await testClientConfig()
await testRawResponse()
await testTypes()
await testErrorHandling()
await testDocumentsCRUD()
await testBatchOperations()
await testSearch()
await testFileUploads()
await testClientConfig()
await testRawResponse()
await testTypes()
await testErrorHandling()
console.log("\n====================")
console.log("✅ All TypeScript SDK tests passed!")
console.log("\n====================")
console.log("✅ All TypeScript SDK tests passed!")
}
main().catch(console.error)

View file

@ -4,107 +4,115 @@ import Supermemory from "supermemory"
const client = new Supermemory()
async function testSearchModes() {
console.log("=== Search Modes ===")
console.log("=== Search Modes ===")
// Hybrid: memories + document chunks (recommended)
const hybridResults = await client.search.memories({
q: "quarterly goals",
containerTag: "user_123",
searchMode: "hybrid",
})
console.log("✓ hybrid search:", hybridResults.results.length, "results")
// Hybrid: memories + document chunks (recommended)
const hybridResults = await client.search.memories({
q: "quarterly goals",
containerTag: "user_123",
searchMode: "hybrid",
})
console.log("✓ hybrid search:", hybridResults.results.length, "results")
// Memories only
const memoriesResults = await client.search.memories({
q: "user preferences",
containerTag: "user_123",
searchMode: "memories",
})
console.log("✓ memories search:", memoriesResults.results.length, "results")
// Memories only
const memoriesResults = await client.search.memories({
q: "user preferences",
containerTag: "user_123",
searchMode: "memories",
})
console.log("✓ memories search:", memoriesResults.results.length, "results")
}
async function testFiltering() {
console.log("\n=== Filtering ===")
console.log("\n=== Filtering ===")
// Basic containerTag filter
const results = await client.search.memories({
q: "project updates",
containerTag: "user_123",
searchMode: "hybrid",
})
console.log("✓ containerTag filter:", results.results.length, "results")
// Basic containerTag filter
const results = await client.search.memories({
q: "project updates",
containerTag: "user_123",
searchMode: "hybrid",
})
console.log("✓ containerTag filter:", results.results.length, "results")
// Metadata-based filtering
const filteredResults = await client.search.memories({
q: "meeting notes",
containerTag: "user_123",
filters: {
AND: [
{ key: "type", value: "meeting" },
{ key: "year", value: "2024" },
],
},
})
console.log("✓ metadata filter:", filteredResults.results.length, "results")
// Metadata-based filtering
const filteredResults = await client.search.memories({
q: "meeting notes",
containerTag: "user_123",
filters: {
AND: [
{ key: "type", value: "meeting" },
{ key: "year", value: "2024" },
],
},
})
console.log("✓ metadata filter:", filteredResults.results.length, "results")
}
async function testReranking() {
console.log("\n=== Reranking ===")
console.log("\n=== Reranking ===")
const results = await client.search.memories({
q: "complex technical question",
containerTag: "user_123",
rerank: true,
})
console.log("✓ reranking:", results.results.length, "results")
const results = await client.search.memories({
q: "complex technical question",
containerTag: "user_123",
rerank: true,
})
console.log("✓ reranking:", results.results.length, "results")
}
async function testThreshold() {
console.log("\n=== Threshold ===")
console.log("\n=== Threshold ===")
const broadResults = await client.search.memories({
q: "test query",
threshold: 0.3,
})
console.log("✓ broad threshold (0.3):", broadResults.results.length, "results")
const broadResults = await client.search.memories({
q: "test query",
threshold: 0.3,
})
console.log(
"✓ broad threshold (0.3):",
broadResults.results.length,
"results",
)
const preciseResults = await client.search.memories({
q: "test query",
threshold: 0.8,
})
console.log("✓ precise threshold (0.8):", preciseResults.results.length, "results")
const preciseResults = await client.search.memories({
q: "test query",
threshold: 0.8,
})
console.log(
"✓ precise threshold (0.8):",
preciseResults.results.length,
"results",
)
}
async function testChatbotContext() {
console.log("\n=== Chatbot Context Pattern ===")
console.log("\n=== Chatbot Context Pattern ===")
async function getContext(userId: string, message: string) {
const results = await client.search.memories({
q: message,
containerTag: userId,
searchMode: "hybrid",
threshold: 0.6,
limit: 5,
})
return results.results.map((r) => r.memory || r.chunk).join("\n\n")
}
async function getContext(userId: string, message: string) {
const results = await client.search.memories({
q: message,
containerTag: userId,
searchMode: "hybrid",
threshold: 0.6,
limit: 5,
})
return results.results.map((r) => r.memory || r.chunk).join("\n\n")
}
const context = await getContext("user_123", "What are the project updates?")
console.log("✓ chatbot context:", context.length, "chars")
const context = await getContext("user_123", "What are the project updates?")
console.log("✓ chatbot context:", context.length, "chars")
}
async function main() {
console.log("Search Tests")
console.log("============\n")
console.log("Search Tests")
console.log("============\n")
await testSearchModes()
await testFiltering()
await testReranking()
await testThreshold()
await testChatbotContext()
await testSearchModes()
await testFiltering()
await testReranking()
await testThreshold()
await testChatbotContext()
console.log("\n============")
console.log("✅ All search tests passed!")
console.log("\n============")
console.log("✅ All search tests passed!")
}
main().catch(console.error)

View file

@ -5,58 +5,62 @@ const client = new Supermemory()
const USER_ID = "docs-test-profiles"
async function testBasicProfile() {
console.log("=== Basic Profile ===")
console.log("=== Basic Profile ===")
const profile = await client.profile({
containerTag: USER_ID,
q: "What are my preferences?",
})
const profile = await client.profile({
containerTag: USER_ID,
q: "What are my preferences?",
})
console.log("✓ Static profile:", profile.profile.static.length, "items")
console.log("✓ Dynamic profile:", profile.profile.dynamic.length, "items")
console.log("✓ Search results:", profile.searchResults.results.length, "items")
console.log("✓ Static profile:", profile.profile.static.length, "items")
console.log("✓ Dynamic profile:", profile.profile.dynamic.length, "items")
console.log(
"✓ Search results:",
profile.searchResults.results.length,
"items",
)
}
async function testProfileWithMemories() {
console.log("\n=== Profile with Memory Context ===")
console.log("\n=== Profile with Memory Context ===")
// First add some memories
await client.add({
content: "User prefers dark mode for all applications",
containerTag: USER_ID,
})
await client.add({
content: "User is learning TypeScript and Rust",
containerTag: USER_ID,
})
// First add some memories
await client.add({
content: "User prefers dark mode for all applications",
containerTag: USER_ID,
})
await client.add({
content: "User is learning TypeScript and Rust",
containerTag: USER_ID,
})
// Wait a bit for indexing
await new Promise((r) => setTimeout(r, 2000))
// Wait a bit for indexing
await new Promise((r) => setTimeout(r, 2000))
// Now get profile with search
const profile = await client.profile({
containerTag: USER_ID,
q: "What programming languages does the user know?",
})
// Now get profile with search
const profile = await client.profile({
containerTag: USER_ID,
q: "What programming languages does the user know?",
})
console.log("✓ Profile retrieved with memories")
console.log(" Static:", profile.profile.static.slice(0, 2))
console.log(" Dynamic:", profile.profile.dynamic.slice(0, 2))
console.log("✓ Profile retrieved with memories")
console.log(" Static:", profile.profile.static.slice(0, 2))
console.log(" Dynamic:", profile.profile.dynamic.slice(0, 2))
}
async function testBuildingContext() {
console.log("\n=== Building LLM Context ===")
console.log("\n=== Building LLM Context ===")
const conversation = [
{ role: "user", content: "What theme should I use for my IDE?" },
]
const conversation = [
{ role: "user", content: "What theme should I use for my IDE?" },
]
const profile = await client.profile({
containerTag: USER_ID,
q: conversation.at(-1)!.content,
})
const profile = await client.profile({
containerTag: USER_ID,
q: conversation.at(-1)!.content,
})
const context = `User Profile:
const context = `User Profile:
${profile.profile.static.join("\n")}
Recent Context:
@ -65,25 +69,25 @@ ${profile.profile.dynamic.join("\n")}
Relevant Memories:
${profile.searchResults.results.map((r) => r.content).join("\n")}`
console.log("✓ Built context:", context.length, "chars")
console.log("✓ Built context:", context.length, "chars")
const messages = [
{ role: "system", content: `Use this context about the user:\n${context}` },
...conversation,
]
console.log("✓ Messages ready for LLM:", messages.length, "messages")
const messages = [
{ role: "system", content: `Use this context about the user:\n${context}` },
...conversation,
]
console.log("✓ Messages ready for LLM:", messages.length, "messages")
}
async function main() {
console.log("User Profiles Tests")
console.log("===================\n")
console.log("User Profiles Tests")
console.log("===================\n")
await testBasicProfile()
await testProfileWithMemories()
await testBuildingContext()
await testBasicProfile()
await testProfileWithMemories()
await testBuildingContext()
console.log("\n===================")
console.log("✅ All user profile tests passed!")
console.log("\n===================")
console.log("✅ All user profile tests passed!")
}
main().catch(console.error)

View file

@ -43,10 +43,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
.then((org) => {
if (org.metadata?.isConsumer === true) {
console.log("Consumer organization:", org)
setOrg(org)
setOrg(org)
} else {
console.log("ALl orgs:", orgs)
const consumerOrg = orgs?.find((o) => o.metadata?.isConsumer === true)
const consumerOrg = orgs?.find(
(o) => o.metadata?.isConsumer === true,
)
if (consumerOrg) {
setActiveOrg(consumerOrg.slug)
}

View file

@ -84,11 +84,11 @@ export const GraphCanvas = memo<GraphCanvasProps>(
const progress = Math.min(elapsed / duration, 1)
// Ease-out cubic easing for smooth deceleration
const eased = 1 - Math.pow(1 - progress, 3)
const eased = 1 - (1 - progress) ** 3
dimProgress.current = startDim + (targetDim - startDim) * eased
// Force re-render to update canvas during animation
forceRender(prev => prev + 1)
forceRender((prev) => prev + 1)
if (progress < 1) {
dimAnimationRef.current = requestAnimationFrame(animate)
@ -145,8 +145,10 @@ export const GraphCanvas = memo<GraphCanvasProps>(
// Only check nodes in the clicked cell (and neighboring cells for edge cases)
const cellsToCheck = [
cellKey,
`${cellX-1},${cellY}`, `${cellX+1},${cellY}`,
`${cellX},${cellY-1}`, `${cellX},${cellY+1}`,
`${cellX - 1},${cellY}`,
`${cellX + 1},${cellY}`,
`${cellX},${cellY - 1}`,
`${cellX},${cellY + 1}`,
]
// Check from top-most to bottom-most: memory nodes are drawn after documents
@ -360,7 +362,12 @@ export const GraphCanvas = memo<GraphCanvasProps>(
})
// Helper function to draw a single edge path
const drawEdgePath = (edge: typeof edges[0], sourceNode: GraphNode, targetNode: GraphNode, edgeShouldDim: boolean) => {
const drawEdgePath = (
edge: (typeof edges)[0],
sourceNode: GraphNode,
targetNode: GraphNode,
edgeShouldDim: boolean,
) => {
const sourceX = sourceNode.x * zoom + panX
const sourceY = sourceNode.y * zoom + panY
const targetX = targetNode.x * zoom + panX
@ -381,9 +388,7 @@ export const GraphCanvas = memo<GraphCanvasProps>(
const dy = targetY - sourceY
const distance = Math.sqrt(dx * dx + dy * dy)
const controlOffset =
edge.edgeType === "doc-memory"
? 15
: Math.min(30, distance * 0.2)
edge.edgeType === "doc-memory" ? 15 : Math.min(30, distance * 0.2)
ctx.beginPath()
ctx.moveTo(sourceX, sourceY)
@ -398,7 +403,7 @@ export const GraphCanvas = memo<GraphCanvasProps>(
}
// Smooth edge opacity: interpolate between full and 0.05 (dimmed)
const edgeDimOpacity = 1 - (dimProgress.current * 0.95)
const edgeDimOpacity = 1 - dimProgress.current * 0.95
// BATCH 1: Draw all doc-memory edges together
if (docMemoryEdges.length > 0) {
@ -417,7 +422,8 @@ export const GraphCanvas = memo<GraphCanvasProps>(
: edge.target
if (sourceNode && targetNode) {
const edgeShouldDim = selectedNodeId !== null &&
const edgeShouldDim =
selectedNodeId !== null &&
sourceNode.id !== selectedNodeId &&
targetNode.id !== selectedNodeId
const opacity = edgeShouldDim ? edgeDimOpacity : 0.9
@ -444,10 +450,13 @@ export const GraphCanvas = memo<GraphCanvasProps>(
: edge.target
if (sourceNode && targetNode) {
const edgeShouldDim = selectedNodeId !== null &&
const edgeShouldDim =
selectedNodeId !== null &&
sourceNode.id !== selectedNodeId &&
targetNode.id !== selectedNodeId
const opacity = edgeShouldDim ? edgeDimOpacity : Math.max(0, edge.similarity * 0.5)
const opacity = edgeShouldDim
? edgeDimOpacity
: Math.max(0, edge.similarity * 0.5)
const lineWidth = Math.max(1, edge.similarity * 2)
// Set color based on similarity strength
@ -480,7 +489,8 @@ export const GraphCanvas = memo<GraphCanvasProps>(
: edge.target
if (sourceNode && targetNode) {
const edgeShouldDim = selectedNodeId !== null &&
const edgeShouldDim =
selectedNodeId !== null &&
sourceNode.id !== selectedNodeId &&
targetNode.id !== selectedNodeId
const opacity = edgeShouldDim ? edgeDimOpacity : 0.8
@ -568,7 +578,7 @@ export const GraphCanvas = memo<GraphCanvasProps>(
const isSelected = selectedNodeId === node.id
const shouldDim = selectedNodeId !== null && !isSelected
// Smooth opacity: interpolate between 1 (full) and 0.1 (dimmed) based on animation progress
const nodeOpacity = shouldDim ? 1 - (dimProgress.current * 0.9) : 1
const nodeOpacity = shouldDim ? 1 - dimProgress.current * 0.9 : 1
const isHighlightedDocument = (() => {
if (node.type !== "document" || highlightSet.size === 0) return false
const doc = node.data as DocumentWithMemories
@ -710,7 +720,7 @@ export const GraphCanvas = memo<GraphCanvasProps>(
const radius = nodeSize / 2
ctx.fillStyle = fillColor
ctx.globalAlpha = shouldDim ? nodeOpacity : (isLatest ? 1 : 0.4)
ctx.globalAlpha = shouldDim ? nodeOpacity : isLatest ? 1 : 0.4
ctx.strokeStyle = borderColor
ctx.lineWidth = isDragging ? 3 : isHovered ? 2 : 1.5
@ -833,7 +843,17 @@ export const GraphCanvas = memo<GraphCanvasProps>(
})
ctx.globalAlpha = 1
}, [nodes, edges, panX, panY, zoom, width, height, highlightDocumentIds, nodeMap])
}, [
nodes,
edges,
panX,
panY,
zoom,
width,
height,
highlightDocumentIds,
nodeMap,
])
// Hybrid rendering: continuous when simulation active, change-based when idle
const lastRenderParams = useRef<number>(0)
@ -857,9 +877,16 @@ export const GraphCanvas = memo<GraphCanvasProps>(
}, 0)
// Combine all factors into a single number
return positionHash ^ edges.length ^
Math.round(panX) ^ Math.round(panY) ^
Math.round(zoom * 100) ^ width ^ height ^ highlightHash
return (
positionHash ^
edges.length ^
Math.round(panX) ^
Math.round(panY) ^
Math.round(zoom * 100) ^
width ^
height ^
highlightHash
)
}, [
nodes,
edges.length,
@ -962,13 +989,10 @@ export const GraphCanvas = memo<GraphCanvasProps>(
// Calculate effective DPR that keeps us within safe limits
// Prevent division by zero by checking for valid dimensions
const maxDpr = width > 0 && height > 0
? Math.min(
MAX_CANVAS_SIZE / width,
MAX_CANVAS_SIZE / height,
dpr
)
: dpr
const maxDpr =
width > 0 && height > 0
? Math.min(MAX_CANVAS_SIZE / width, MAX_CANVAS_SIZE / height, dpr)
: dpr
// upscale backing store with clamped dimensions
canvas.style.width = `${width}px`
@ -1026,4 +1050,4 @@ export const GraphCanvas = memo<GraphCanvasProps>(
},
)
GraphCanvas.displayName = "GraphCanvas"
GraphCanvas.displayName = "GraphCanvas"

View file

@ -2,7 +2,14 @@
import { GlassMenuEffect } from "@/ui/glass-effect"
import { AnimatePresence } from "motion/react"
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"
import {
useCallback,
useEffect,
useMemo,
useReducer,
useRef,
useState,
} from "react"
import { GraphCanvas } from "./graph-canvas"
import { useGraphData } from "@/hooks/use-graph-data"
import { useGraphInteractions } from "@/hooks/use-graph-interactions"
@ -426,8 +433,10 @@ export const MemoryGraph = ({
// Calculate node dimensions to position popover with proper gap
const nodeSize = selectedNodeData.size * zoom
const nodeWidth = selectedNodeData.type === "document" ? nodeSize * 1.4 : nodeSize
const nodeHeight = selectedNodeData.type === "document" ? nodeSize * 0.9 : nodeSize
const nodeWidth =
selectedNodeData.type === "document" ? nodeSize * 1.4 : nodeSize
const nodeHeight =
selectedNodeData.type === "document" ? nodeSize * 0.9 : nodeSize
const gap = 20 // Gap between node and popover
// Smart positioning: flip to other side if would go off-screen
@ -457,7 +466,14 @@ export const MemoryGraph = ({
}
return { x: popoverX, y: popoverY }
}, [selectedNodeData, zoom, panX, panY, containerSize.width, containerSize.height])
}, [
selectedNodeData,
zoom,
panX,
panY,
containerSize.width,
containerSize.height,
])
// Viewport-based loading: load more when most documents are visible (optional)
const checkAndLoadMore = useCallback(() => {
@ -564,7 +580,14 @@ export const MemoryGraph = ({
containerSizeRef.current = containerSize
onSlideshowNodeChangeRef.current = onSlideshowNodeChange
forceSimulationRef.current = forceSimulation
}, [nodes, handleNodeClick, centerViewportOn, containerSize, onSlideshowNodeChange, forceSimulation])
}, [
nodes,
handleNodeClick,
centerViewportOn,
containerSize,
onSlideshowNodeChange,
forceSimulation,
])
useEffect(() => {
// Clear any existing interval and timeout when isSlideshowActive changes
@ -731,7 +754,7 @@ export const MemoryGraph = ({
height={containerSize.height}
nodes={nodes}
highlightDocumentIds={highlightsVisible ? highlightDocumentIds : []}
isSimulationActive={forceSimulation.isActive()}
isSimulationActive={forceSimulation.isActive()}
onDoubleClick={handleDoubleClick}
onNodeClick={handleNodeClickWithPhysics}
onNodeDragEnd={handleNodeDragEndWithPhysics}

View file

@ -17,7 +17,7 @@ const navButtonBase = style({
backgroundColor: "rgba(0, 0, 0, 0.2)",
backdropFilter: "blur(8px)",
WebkitBackdropFilter: "blur(8px)",
border: `1px solid rgba(255, 255, 255, 0.1)`,
border: "1px solid rgba(255, 255, 255, 0.1)",
borderRadius: themeContract.radii.lg,
padding: themeContract.space[2],
color: "rgba(255, 255, 255, 0.7)",

View file

@ -68,7 +68,7 @@ const getDocumentIcon = (type: string) => {
return <PDF {...iconProps} />
default:
{
/*@ts-ignore */
/*@ts-expect-error */
}
return <FileText {...iconProps} />
}
@ -109,7 +109,7 @@ export const NodeDetailPanel = memo(function NodeDetailPanel({
{isDocument ? (
getDocumentIcon((data as DocumentWithMemories).type ?? "")
) : (
// @ts-ignore
// @ts-expect-error
<Brain className={styles.headerIconMemory} />
)}
<HeadingH3Bold>{isDocument ? "Document" : "Memory"}</HeadingH3Bold>

View file

@ -55,7 +55,11 @@ export const NodePopover = memo<NodePopoverProps>(function NodePopover({
return (
<>
{/* Invisible backdrop to catch clicks outside */}
<div onClick={handleBackdropClick} className={backdropClassName} style={backdropStyle} />
<div
onClick={handleBackdropClick}
className={backdropClassName}
style={backdropStyle}
/>
{/* Popover content */}
<div
@ -72,16 +76,24 @@ export const NodePopover = memo<NodePopoverProps>(function NodePopover({
{/* Header */}
<div className={styles.header}>
<div className={styles.headerTitle}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={styles.headerIcon}>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="16" y1="13" x2="8" y2="13"></line>
<line x1="16" y1="17" x2="8" y2="17"></line>
<polyline points="10 9 9 9 8 9"></polyline>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={styles.headerIcon}
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
<h3 className={styles.title}>
Document
</h3>
<h3 className={styles.title}>Document</h3>
</div>
<button
type="button"
@ -96,9 +108,7 @@ export const NodePopover = memo<NodePopoverProps>(function NodePopover({
<div className={styles.sectionsContainer}>
{/* Title */}
<div>
<div className={styles.fieldLabel}>
Title
</div>
<div className={styles.fieldLabel}>Title</div>
<p className={styles.fieldValue}>
{(node.data as any).title || "Untitled Document"}
</p>
@ -107,9 +117,7 @@ export const NodePopover = memo<NodePopoverProps>(function NodePopover({
{/* Summary - truncated to 2 lines */}
{(node.data as any).summary && (
<div>
<div className={styles.fieldLabel}>
Summary
</div>
<div className={styles.fieldLabel}>Summary</div>
<p className={styles.summaryValue}>
{(node.data as any).summary}
</p>
@ -118,9 +126,7 @@ export const NodePopover = memo<NodePopoverProps>(function NodePopover({
{/* Type */}
<div>
<div className={styles.fieldLabel}>
Type
</div>
<div className={styles.fieldLabel}>Type</div>
<p className={styles.fieldValue}>
{(node.data as any).type || "Document"}
</p>
@ -128,9 +134,7 @@ export const NodePopover = memo<NodePopoverProps>(function NodePopover({
{/* Memory Count */}
<div>
<div className={styles.fieldLabel}>
Memory Count
</div>
<div className={styles.fieldLabel}>Memory Count</div>
<p className={styles.fieldValue}>
{(node.data as any).memoryEntries?.length || 0} memories
</p>
@ -139,9 +143,7 @@ export const NodePopover = memo<NodePopoverProps>(function NodePopover({
{/* URL */}
{((node.data as any).url || (node.data as any).customId) && (
<div>
<div className={styles.fieldLabel}>
URL
</div>
<div className={styles.fieldLabel}>URL</div>
<a
href={(() => {
const doc = node.data as any
@ -160,10 +162,19 @@ export const NodePopover = memo<NodePopoverProps>(function NodePopover({
rel="noopener noreferrer"
className={styles.link}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
<polyline points="15 3 21 3 21 9"></polyline>
<line x1="10" y1="14" x2="21" y2="3"></line>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
View Document
</a>
@ -173,20 +184,42 @@ export const NodePopover = memo<NodePopoverProps>(function NodePopover({
{/* Footer with metadata */}
<div className={styles.footer}>
<div className={styles.footerItem}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
<span>{new Date((node.data as any).createdAt).toLocaleDateString()}</span>
<span>
{new Date(
(node.data as any).createdAt,
).toLocaleDateString()}
</span>
</div>
<div className={styles.footerItemId}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="4" y1="9" x2="20" y2="9"></line>
<line x1="4" y1="15" x2="20" y2="15"></line>
<line x1="10" y1="3" x2="8" y2="21"></line>
<line x1="16" y1="3" x2="14" y2="21"></line>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="4" y1="9" x2="20" y2="9" />
<line x1="4" y1="15" x2="20" y2="15" />
<line x1="10" y1="3" x2="8" y2="21" />
<line x1="16" y1="3" x2="14" y2="21" />
</svg>
<span className={styles.idText}>{node.id}</span>
</div>
@ -199,13 +232,21 @@ export const NodePopover = memo<NodePopoverProps>(function NodePopover({
{/* Header */}
<div className={styles.header}>
<div className={styles.headerTitle}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={styles.headerIconMemory}>
<path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2Z"></path>
<path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96.44 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2Z"></path>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={styles.headerIconMemory}
>
<path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2Z" />
<path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96.44 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2Z" />
</svg>
<h3 className={styles.title}>
Memory
</h3>
<h3 className={styles.title}>Memory</h3>
</div>
<button
type="button"
@ -220,31 +261,31 @@ export const NodePopover = memo<NodePopoverProps>(function NodePopover({
<div className={styles.sectionsContainer}>
{/* Memory content */}
<div>
<div className={styles.fieldLabel}>
Memory
</div>
<div className={styles.fieldLabel}>Memory</div>
<p className={styles.fieldValue}>
{(node.data as any).memory || (node.data as any).content || "No content"}
{(node.data as any).memory ||
(node.data as any).content ||
"No content"}
</p>
{(node.data as any).isForgotten && (
<div className={styles.forgottenBadge}>
Forgotten
</div>
<div className={styles.forgottenBadge}>Forgotten</div>
)}
{/* Expires (inline with memory if exists) */}
{(node.data as any).forgetAfter && (
<p className={styles.expiresText}>
Expires: {new Date((node.data as any).forgetAfter).toLocaleDateString()}
{(node.data as any).forgetReason && ` - ${(node.data as any).forgetReason}`}
Expires:{" "}
{new Date(
(node.data as any).forgetAfter,
).toLocaleDateString()}
{(node.data as any).forgetReason &&
` - ${(node.data as any).forgetReason}`}
</p>
)}
</div>
{/* Space */}
<div>
<div className={styles.fieldLabel}>
Space
</div>
<div className={styles.fieldLabel}>Space</div>
<p className={styles.fieldValue}>
{(node.data as any).spaceId || "Default"}
</p>
@ -253,20 +294,42 @@ export const NodePopover = memo<NodePopoverProps>(function NodePopover({
{/* Footer with metadata */}
<div className={styles.footer}>
<div className={styles.footerItem}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
<span>{new Date((node.data as any).createdAt).toLocaleDateString()}</span>
<span>
{new Date(
(node.data as any).createdAt,
).toLocaleDateString()}
</span>
</div>
<div className={styles.footerItemId}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="4" y1="9" x2="20" y2="9"></line>
<line x1="4" y1="15" x2="20" y2="15"></line>
<line x1="10" y1="3" x2="8" y2="21"></line>
<line x1="16" y1="3" x2="14" y2="21"></line>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="4" y1="9" x2="20" y2="9" />
<line x1="4" y1="15" x2="20" y2="15" />
<line x1="10" y1="3" x2="8" y2="21" />
<line x1="16" y1="3" x2="14" y2="21" />
</svg>
<span className={styles.idText}>{node.id}</span>
</div>

View file

@ -127,9 +127,8 @@ export function useForceSimulation(
// Update edges
if (edges.length > 0) {
const linkForce = simulationRef.current.force<
d3.ForceLink<GraphNode, GraphEdge>
>("link")
const linkForce =
simulationRef.current.force<d3.ForceLink<GraphNode, GraphEdge>>("link")
if (linkForce) {
linkForce.links(edges)
}

View file

@ -19,7 +19,16 @@ import type {
export function useGraphData(
data: DocumentsResponse | null,
selectedSpace: string,
nodePositions: Map<string, { x: number; y: number; parentDocId?: string; offsetX?: number; offsetY?: number }>,
nodePositions: Map<
string,
{
x: number
y: number
parentDocId?: string
offsetX?: number
offsetY?: number
}
>,
draggingNodeId: string | null,
memoryLimit?: number,
maxNodes?: number,
@ -60,35 +69,34 @@ export function useGraphData(
})
// Filter by space and prepare documents
let processedDocs = sortedDocs
.map((doc) => {
let memories =
selectedSpace === "all"
? doc.memoryEntries
: doc.memoryEntries.filter(
(memory) =>
(memory.spaceContainerTag ?? memory.spaceId ?? "default") ===
selectedSpace,
)
let processedDocs = sortedDocs.map((doc) => {
let memories =
selectedSpace === "all"
? doc.memoryEntries
: doc.memoryEntries.filter(
(memory) =>
(memory.spaceContainerTag ?? memory.spaceId ?? "default") ===
selectedSpace,
)
// Sort memories by relevance score (if available) or recency
memories = memories.sort((a, b) => {
// Prioritize sourceRelevanceScore if available
if (a.sourceRelevanceScore != null && b.sourceRelevanceScore != null) {
return b.sourceRelevanceScore - a.sourceRelevanceScore // Higher score first
}
// Fall back to most recent
const dateA = new Date(a.updatedAt || a.createdAt).getTime()
const dateB = new Date(b.updatedAt || b.createdAt).getTime()
return dateB - dateA // Most recent first
})
return {
...doc,
memoryEntries: memories,
// Sort memories by relevance score (if available) or recency
memories = memories.sort((a, b) => {
// Prioritize sourceRelevanceScore if available
if (a.sourceRelevanceScore != null && b.sourceRelevanceScore != null) {
return b.sourceRelevanceScore - a.sourceRelevanceScore // Higher score first
}
// Fall back to most recent
const dateA = new Date(a.updatedAt || a.createdAt).getTime()
const dateB = new Date(b.updatedAt || b.createdAt).getTime()
return dateB - dateA // Most recent first
})
return {
...doc,
memoryEntries: memories,
}
})
// Apply maxNodes limit using Option B (dynamic cap per document)
if (maxNodes && maxNodes > 0) {
const totalDocs = processedDocs.length
@ -112,37 +120,57 @@ export function useGraphData(
// If we still have budget left, distribute remaining nodes to first docs
let remainingBudget = maxNodes - totalNodes
if (remainingBudget > 0) {
for (let i = 0; i < processedDocs.length && remainingBudget > 0; i++) {
for (
let i = 0;
i < processedDocs.length && remainingBudget > 0;
i++
) {
const doc = processedDocs[i]
if (!doc) continue
const originalDoc = sortedDocs.find(d => d.id === doc.id)
const originalDoc = sortedDocs.find((d) => d.id === doc.id)
if (!originalDoc) continue
const currentMemCount = doc.memoryEntries.length
const originalMemCount = originalDoc.memoryEntries.filter(
m => selectedSpace === "all" ||
(m.spaceContainerTag ?? m.spaceId ?? "default") === selectedSpace
(m) =>
selectedSpace === "all" ||
(m.spaceContainerTag ?? m.spaceId ?? "default") ===
selectedSpace,
).length
// Can we add more memories to this doc?
const canAdd = originalMemCount - currentMemCount
if (canAdd > 0) {
const toAdd = Math.min(canAdd, remainingBudget)
const additionalMems = doc.memoryEntries.slice(0, currentMemCount + toAdd)
const additionalMems = doc.memoryEntries.slice(
0,
currentMemCount + toAdd,
)
processedDocs[i] = {
...doc,
memoryEntries: originalDoc.memoryEntries
.filter(m => selectedSpace === "all" ||
(m.spaceContainerTag ?? m.spaceId ?? "default") === selectedSpace)
.filter(
(m) =>
selectedSpace === "all" ||
(m.spaceContainerTag ?? m.spaceId ?? "default") ===
selectedSpace,
)
.sort((a, b) => {
if (a.sourceRelevanceScore != null && b.sourceRelevanceScore != null) {
if (
a.sourceRelevanceScore != null &&
b.sourceRelevanceScore != null
) {
return b.sourceRelevanceScore - a.sourceRelevanceScore
}
const dateA = new Date(a.updatedAt || a.createdAt).getTime()
const dateB = new Date(b.updatedAt || b.createdAt).getTime()
const dateA = new Date(
a.updatedAt || a.createdAt,
).getTime()
const dateB = new Date(
b.updatedAt || b.createdAt,
).getTime()
return dateB - dateA
})
.slice(0, currentMemCount + toAdd)
.slice(0, currentMemCount + toAdd),
}
remainingBudget -= toAdd
}
@ -238,8 +266,7 @@ export function useGraphData(
})
// Enhanced Layout with Space Separation
const { centerX, centerY, clusterRadius } =
LAYOUT_CONSTANTS
const { centerX, centerY, clusterRadius } = LAYOUT_CONSTANTS
/* 1. Build DOCUMENT nodes with space-aware clustering */
const documentNodes: GraphNode[] = []
@ -255,8 +282,10 @@ export function useGraphData(
// Loose grid spacing - physics will organize it better
const spacing = 200
const defaultX = centerX + (col - gridSize / 2) * spacing + (Math.random() - 0.5) * 50
const defaultY = centerY + (row - gridSize / 2) * spacing + (Math.random() - 0.5) * 50
const defaultX =
centerX + (col - gridSize / 2) * spacing + (Math.random() - 0.5) * 50
const defaultY =
centerY + (row - gridSize / 2) * spacing + (Math.random() - 0.5) * 50
const customPos = nodePositions.get(doc.id)
@ -294,7 +323,7 @@ export function useGraphData(
// D3-force will handle collision avoidance and spacing dynamically
allNodes.push(...documentNodes)
/* 3. Add memories around documents WITH doc-memory connections */
documentNodes.forEach((docNode) => {
const memoryNodeMap = new Map<string, GraphNode>()
@ -318,9 +347,11 @@ export function useGraphData(
if (customMemPos) {
// If memory was manually positioned and has stored offset relative to parent
if (customMemPos.parentDocId === docNode.id &&
if (
customMemPos.parentDocId === docNode.id &&
customMemPos.offsetX !== undefined &&
customMemPos.offsetY !== undefined) {
customMemPos.offsetY !== undefined
) {
// Apply the stored offset to the current document position
finalMemX = docNode.x + customMemPos.offsetX
finalMemY = docNode.y + customMemPos.offsetY
@ -386,7 +417,8 @@ export function useGraphData(
data.documents.forEach((doc) => {
doc.memoryEntries.forEach((mem: MemoryEntry) => {
// Support both new object structure and legacy array/single parent fields
let parentRelations: Record<string, MemoryRelation> = (mem.memoryRelations ?? {}) as Record<string, MemoryRelation>
let parentRelations: Record<string, MemoryRelation> =
(mem.memoryRelations ?? {}) as Record<string, MemoryRelation>
if (
mem.memoryRelations &&
@ -436,4 +468,4 @@ export function useGraphData(
return { nodes: allNodes, edges: allEdges }
}, [data, filteredDocuments, nodePositions, draggingNodeId, similarityEdges])
}
}

View file

@ -24,7 +24,16 @@ export function useGraphInteractions(
nodeY: 0,
})
const [nodePositions, setNodePositions] = useState<
Map<string, { x: number; y: number; parentDocId?: string; offsetX?: number; offsetY?: number }>
Map<
string,
{
x: number
y: number
parentDocId?: string
offsetX?: number
offsetY?: number
}
>
>(new Map())
// Touch gesture state
@ -125,8 +134,11 @@ export function useGraphInteractions(
// For memory nodes, find the parent document and store relative offset
const memoryData = draggedNode.data as any // MemoryEntry type
const parentDoc = nodes?.find(
(n) => n.type === "document" &&
(n.data as any).memoryEntries?.some((m: any) => m.id === memoryData.id)
(n) =>
n.type === "document" &&
(n.data as any).memoryEntries?.some(
(m: any) => m.id === memoryData.id,
),
)
if (parentDoc) {
@ -140,7 +152,7 @@ export function useGraphInteractions(
y: newY,
parentDocId: parentDoc.id,
offsetX,
offsetY
offsetY,
}),
)
return

View file

@ -4,68 +4,68 @@
* Shows the cleanest way to integrate Claude's memory tool with supermemory
*/
import Anthropic from '@anthropic-ai/sdk'
import { createClaudeMemoryTool } from './claude-memory'
import Anthropic from "@anthropic-ai/sdk"
import { createClaudeMemoryTool } from "./claude-memory"
const anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY!,
apiKey: process.env.ANTHROPIC_API_KEY!,
})
const memoryTool = createClaudeMemoryTool(process.env.SUPERMEMORY_API_KEY!, {
projectId: 'my-app',
projectId: "my-app",
})
async function chatWithMemory(userMessage: string) {
// Send message to Claude with memory tool
const response = await anthropic.beta.messages.create({
model: 'claude-sonnet-4-5',
max_tokens: 2048,
messages: [{ role: 'user', content: userMessage }],
tools: [{ type: 'memory_20250818', name: 'memory' }],
betas: ['context-management-2025-06-27'],
})
// Send message to Claude with memory tool
const response = await anthropic.beta.messages.create({
model: "claude-sonnet-4-5",
max_tokens: 2048,
messages: [{ role: "user", content: userMessage }],
tools: [{ type: "memory_20250818", name: "memory" }],
betas: ["context-management-2025-06-27"],
})
// Handle any memory tool calls
const toolResults = []
for (const block of response.content) {
if (block.type === 'tool_use' && block.name === 'memory') {
const toolResult = await memoryTool.handleCommandForToolResult(
block.input as any,
block.id
)
toolResults.push(toolResult)
}
}
// Handle any memory tool calls
const toolResults = []
for (const block of response.content) {
if (block.type === "tool_use" && block.name === "memory") {
const toolResult = await memoryTool.handleCommandForToolResult(
block.input as any,
block.id,
)
toolResults.push(toolResult)
}
}
// Send tool results back to Claude if needed
if (toolResults.length > 0) {
const finalResponse = await anthropic.beta.messages.create({
model: 'claude-sonnet-4-5',
max_tokens: 2048,
messages: [
{ role: 'user', content: userMessage },
{ role: 'assistant', content: response.content },
{ role: 'user', content: toolResults },
],
tools: [{ type: 'memory_20250818', name: 'memory' }],
betas: ['context-management-2025-06-27'],
})
// Send tool results back to Claude if needed
if (toolResults.length > 0) {
const finalResponse = await anthropic.beta.messages.create({
model: "claude-sonnet-4-5",
max_tokens: 2048,
messages: [
{ role: "user", content: userMessage },
{ role: "assistant", content: response.content },
{ role: "user", content: toolResults },
],
tools: [{ type: "memory_20250818", name: "memory" }],
betas: ["context-management-2025-06-27"],
})
return finalResponse
}
return finalResponse
}
return response
return response
}
// Example usage
async function example() {
const response = await chatWithMemory(
"Remember that I prefer React with TypeScript for my projects"
)
const response = await chatWithMemory(
"Remember that I prefer React with TypeScript for my projects",
)
console.log(response.content[0])
console.log(response.content[0])
}
if (import.meta.main) {
example()
}
example()
}

View file

@ -32,12 +32,13 @@ async function testResponses() {
console.log("\n=== Testing Responses API ===")
const response = await openaiWithSupermemory.chat.completions.create({
model: "gpt-4o",
messages: [
{ role: "user", content: "what's my favoritge color?" },
],
messages: [{ role: "user", content: "what's my favoritge color?" }],
})
console.log("Response:", JSON.stringify(response.choices[0]?.message.content, null, 2))
console.log(
"Response:",
JSON.stringify(response.choices[0]?.message.content, null, 2),
)
}
// Run tests

View file

@ -14,4 +14,4 @@ const result = await generateText({
messages: [{ role: "user", content: "Where do i live?" }],
})
console.log(result.text)
console.log(result.text)

View file

@ -4,257 +4,281 @@
* Shows how to use the memory tool with the official Anthropic SDK
*/
import Anthropic from '@anthropic-ai/sdk'
import { createClaudeMemoryTool } from './claude-memory'
import 'dotenv/config'
import Anthropic from "@anthropic-ai/sdk"
import { createClaudeMemoryTool } from "./claude-memory"
import "dotenv/config"
/**
* Handle Claude's memory tool calls using the Anthropic SDK
*/
async function chatWithMemoryTool() {
console.log('🤖 Anthropic SDK Example - Claude with Memory Tool')
console.log('=' .repeat(50))
console.log("🤖 Anthropic SDK Example - Claude with Memory Tool")
console.log("=".repeat(50))
const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY
const SUPERMEMORY_API_KEY = process.env.SUPERMEMORY_API_KEY
const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY
const SUPERMEMORY_API_KEY = process.env.SUPERMEMORY_API_KEY
if (!ANTHROPIC_API_KEY || !SUPERMEMORY_API_KEY) {
console.error('❌ Missing required API keys:')
console.error('- ANTHROPIC_API_KEY')
console.error('- SUPERMEMORY_API_KEY')
return
}
if (!ANTHROPIC_API_KEY || !SUPERMEMORY_API_KEY) {
console.error("❌ Missing required API keys:")
console.error("- ANTHROPIC_API_KEY")
console.error("- SUPERMEMORY_API_KEY")
return
}
// Initialize Anthropic client
const anthropic = new Anthropic({
apiKey: ANTHROPIC_API_KEY,
})
// Initialize Anthropic client
const anthropic = new Anthropic({
apiKey: ANTHROPIC_API_KEY,
})
// Initialize memory tool
const memoryTool = createClaudeMemoryTool(SUPERMEMORY_API_KEY, {
projectId: 'anthropic-sdk-demo',
memoryContainerTag: 'claude_memory_anthropic',
})
// Initialize memory tool
const memoryTool = createClaudeMemoryTool(SUPERMEMORY_API_KEY, {
projectId: "anthropic-sdk-demo",
memoryContainerTag: "claude_memory_anthropic",
})
// Conversation messages
const messages: Anthropic.Messages.MessageParam[] = [
{
role: 'user',
content: "Hi Claude! I'm working on a new React project using TypeScript and I want you to remember my preferences. Can you help me debug some code later?",
},
]
// Conversation messages
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content:
"Hi Claude! I'm working on a new React project using TypeScript and I want you to remember my preferences. Can you help me debug some code later?",
},
]
console.log('💬 User:', messages[0].content)
console.log('\n🔄 Sending to Claude with memory tool...')
console.log("💬 User:", messages[0].content)
console.log("\n🔄 Sending to Claude with memory tool...")
try {
// Make initial request to Claude with memory tool
const response = await anthropic.beta.messages.create({
model: 'claude-sonnet-4-5',
max_tokens: 2048,
messages: messages,
tools: [
{
type: 'memory_20250818',
name: 'memory',
},
],
betas: ['context-management-2025-06-27'],
})
try {
// Make initial request to Claude with memory tool
const response = await anthropic.beta.messages.create({
model: "claude-sonnet-4-5",
max_tokens: 2048,
messages: messages,
tools: [
{
type: "memory_20250818",
name: "memory",
},
],
betas: ["context-management-2025-06-27"],
})
console.log('📥 Claude responded:')
console.log("📥 Claude responded:")
// Process the response
let toolResults: Anthropic.Messages.ToolResultBlockParam[] = []
// Process the response
const toolResults: Anthropic.Messages.ToolResultBlockParam[] = []
for (const block of response.content) {
if (block.type === 'text') {
console.log('💭', block.text)
} else if (block.type === 'tool_use' && block.name === 'memory') {
console.log('🔧 Claude is using memory tool:')
console.log(' Command:', block.input.command)
console.log(' Path:', block.input.path)
for (const block of response.content) {
if (block.type === "text") {
console.log("💭", block.text)
} else if (block.type === "tool_use" && block.name === "memory") {
console.log("🔧 Claude is using memory tool:")
console.log(" Command:", block.input.command)
console.log(" Path:", block.input.path)
// Handle the memory tool call
const memoryResult = await memoryTool.handleCommand(block.input as any)
// Handle the memory tool call
const memoryResult = await memoryTool.handleCommand(block.input as any)
const toolResult: Anthropic.Messages.ToolResultBlockParam = {
type: 'tool_result',
tool_use_id: block.id,
content: memoryResult.success
? memoryResult.content || 'Operation completed successfully'
: `Error: ${memoryResult.error}`,
is_error: !memoryResult.success,
}
const toolResult: Anthropic.Messages.ToolResultBlockParam = {
type: "tool_result",
tool_use_id: block.id,
content: memoryResult.success
? memoryResult.content || "Operation completed successfully"
: `Error: ${memoryResult.error}`,
is_error: !memoryResult.success,
}
toolResults.push(toolResult)
toolResults.push(toolResult)
console.log('📊 Memory operation result:', memoryResult.success ? '✅ Success' : '❌ Failed')
if (memoryResult.content) {
console.log('📄 Content preview:', memoryResult.content.substring(0, 100) + '...')
}
}
}
console.log(
"📊 Memory operation result:",
memoryResult.success ? "✅ Success" : "❌ Failed",
)
if (memoryResult.content) {
console.log(
"📄 Content preview:",
memoryResult.content.substring(0, 100) + "...",
)
}
}
}
// If Claude used memory tools, send the results back
if (toolResults.length > 0) {
console.log('\n🔄 Sending tool results back to Claude...')
// If Claude used memory tools, send the results back
if (toolResults.length > 0) {
console.log("\n🔄 Sending tool results back to Claude...")
// Add Claude's response to conversation
messages.push({
role: 'assistant',
content: response.content,
})
// Add Claude's response to conversation
messages.push({
role: "assistant",
content: response.content,
})
// Add tool results
messages.push({
role: 'user',
content: toolResults,
})
// Add tool results
messages.push({
role: "user",
content: toolResults,
})
const finalResponse = await anthropic.beta.messages.create({
model: 'claude-sonnet-4-5',
max_tokens: 2048,
messages: messages,
tools: [
{
type: 'memory_20250818',
name: 'memory',
},
],
betas: ['context-management-2025-06-27'],
})
const finalResponse = await anthropic.beta.messages.create({
model: "claude-sonnet-4-5",
max_tokens: 2048,
messages: messages,
tools: [
{
type: "memory_20250818",
name: "memory",
},
],
betas: ["context-management-2025-06-27"],
})
console.log('📥 Claude\'s final response:')
console.log("📥 Claude's final response:")
for (const block of finalResponse.content) {
if (block.type === 'text') {
console.log('💭', block.text)
} else if (block.type === 'tool_use' && block.name === 'memory') {
console.log('🔧 Claude is using memory tool again:')
console.log(' Command:', block.input.command)
console.log(' Path:', block.input.path)
for (const block of finalResponse.content) {
if (block.type === "text") {
console.log("💭", block.text)
} else if (block.type === "tool_use" && block.name === "memory") {
console.log("🔧 Claude is using memory tool again:")
console.log(" Command:", block.input.command)
console.log(" Path:", block.input.path)
// Handle additional memory tool calls
const memoryResult = await memoryTool.handleCommand(block.input as any)
console.log('📊 Memory operation result:', memoryResult.success ? '✅ Success' : '❌ Failed')
}
}
}
// Handle additional memory tool calls
const memoryResult = await memoryTool.handleCommand(
block.input as any,
)
console.log(
"📊 Memory operation result:",
memoryResult.success ? "✅ Success" : "❌ Failed",
)
}
}
}
console.log('\n✨ Conversation completed!')
console.log('\n📋 Usage statistics:')
console.log('- Input tokens:', response.usage.input_tokens)
console.log('- Output tokens:', response.usage.output_tokens)
console.log('- Memory operations:', toolResults.length)
} catch (error) {
console.error('❌ Error:', error)
}
console.log("\n✨ Conversation completed!")
console.log("\n📋 Usage statistics:")
console.log("- Input tokens:", response.usage.input_tokens)
console.log("- Output tokens:", response.usage.output_tokens)
console.log("- Memory operations:", toolResults.length)
} catch (error) {
console.error("❌ Error:", error)
}
}
/**
* Test memory tool operations directly
*/
async function testMemoryOperations() {
console.log('\n🧪 Testing Memory Operations Directly')
console.log('=' .repeat(50))
console.log("\n🧪 Testing Memory Operations Directly")
console.log("=".repeat(50))
if (!process.env.SUPERMEMORY_API_KEY) {
console.error('❌ SUPERMEMORY_API_KEY is required')
return
}
if (!process.env.SUPERMEMORY_API_KEY) {
console.error("❌ SUPERMEMORY_API_KEY is required")
return
}
const memoryTool = createClaudeMemoryTool(process.env.SUPERMEMORY_API_KEY, {
projectId: 'direct-test',
memoryContainerTag: 'claude_memory_direct',
})
const memoryTool = createClaudeMemoryTool(process.env.SUPERMEMORY_API_KEY, {
projectId: "direct-test",
memoryContainerTag: "claude_memory_direct",
})
const testCases = [
{
name: 'View empty memory directory',
command: { command: 'view' as const, path: '/memories' },
},
{
name: 'Create user preferences file',
command: {
command: 'create' as const,
path: '/memories/user-preferences.md',
file_text: '# User Preferences\n\n- Prefers React with TypeScript\n- Likes clean, readable code\n- Uses functional programming style\n- Prefers ESLint and Prettier for code formatting',
},
},
{
name: 'Create project notes',
command: {
command: 'create' as const,
path: '/memories/project-notes.txt',
file_text: 'Current Project: React TypeScript App\n\nFeatures to implement:\n1. User authentication\n2. Dashboard with widgets\n3. Data visualization\n4. Export functionality\n\nTech stack:\n- React 18\n- TypeScript\n- Vite\n- Tailwind CSS',
},
},
{
name: 'List directory contents',
command: { command: 'view' as const, path: '/memories/' },
},
{
name: 'Read user preferences',
command: { command: 'view' as const, path: '/memories/user-preferences.md' },
},
{
name: 'Update preferences (add VS Code)',
command: {
command: 'str_replace' as const,
path: '/memories/user-preferences.md',
old_str: '- Prefers ESLint and Prettier for code formatting',
new_str: '- Prefers ESLint and Prettier for code formatting\n- Uses VS Code as primary editor\n- Likes GitHub Copilot for code completion',
},
},
{
name: 'Insert new task in project notes',
command: {
command: 'insert' as const,
path: '/memories/project-notes.txt',
insert_line: 6,
insert_text: '5. Unit testing with Jest and React Testing Library',
},
},
{
name: 'Read updated project notes',
command: { command: 'view' as const, path: '/memories/project-notes.txt', view_range: [4, 8] },
},
]
const testCases = [
{
name: "View empty memory directory",
command: { command: "view" as const, path: "/memories" },
},
{
name: "Create user preferences file",
command: {
command: "create" as const,
path: "/memories/user-preferences.md",
file_text:
"# User Preferences\n\n- Prefers React with TypeScript\n- Likes clean, readable code\n- Uses functional programming style\n- Prefers ESLint and Prettier for code formatting",
},
},
{
name: "Create project notes",
command: {
command: "create" as const,
path: "/memories/project-notes.txt",
file_text:
"Current Project: React TypeScript App\n\nFeatures to implement:\n1. User authentication\n2. Dashboard with widgets\n3. Data visualization\n4. Export functionality\n\nTech stack:\n- React 18\n- TypeScript\n- Vite\n- Tailwind CSS",
},
},
{
name: "List directory contents",
command: { command: "view" as const, path: "/memories/" },
},
{
name: "Read user preferences",
command: {
command: "view" as const,
path: "/memories/user-preferences.md",
},
},
{
name: "Update preferences (add VS Code)",
command: {
command: "str_replace" as const,
path: "/memories/user-preferences.md",
old_str: "- Prefers ESLint and Prettier for code formatting",
new_str:
"- Prefers ESLint and Prettier for code formatting\n- Uses VS Code as primary editor\n- Likes GitHub Copilot for code completion",
},
},
{
name: "Insert new task in project notes",
command: {
command: "insert" as const,
path: "/memories/project-notes.txt",
insert_line: 6,
insert_text: "5. Unit testing with Jest and React Testing Library",
},
},
{
name: "Read updated project notes",
command: {
command: "view" as const,
path: "/memories/project-notes.txt",
view_range: [4, 8],
},
},
]
for (const testCase of testCases) {
console.log(`\n🔍 ${testCase.name}`)
try {
const result = await memoryTool.handleCommand(testCase.command)
for (const testCase of testCases) {
console.log(`\n🔍 ${testCase.name}`)
try {
const result = await memoryTool.handleCommand(testCase.command)
if (result.success) {
console.log('✅ Success')
if (result.content && result.content.length <= 200) {
console.log('📄 Result:', result.content)
} else if (result.content) {
console.log('📄 Result:', result.content.substring(0, 150) + '... (truncated)')
}
} else {
console.log('❌ Failed')
console.log('📄 Error:', result.error)
}
} catch (error) {
console.log('💥 Exception:', error)
}
if (result.success) {
console.log("✅ Success")
if (result.content && result.content.length <= 200) {
console.log("📄 Result:", result.content)
} else if (result.content) {
console.log(
"📄 Result:",
result.content.substring(0, 150) + "... (truncated)",
)
}
} else {
console.log("❌ Failed")
console.log("📄 Error:", result.error)
}
} catch (error) {
console.log("💥 Exception:", error)
}
// Small delay to avoid rate limiting
await new Promise(resolve => setTimeout(resolve, 300))
}
// Small delay to avoid rate limiting
await new Promise((resolve) => setTimeout(resolve, 300))
}
}
// Run the examples
async function main() {
await testMemoryOperations()
console.log('\n' + '=' .repeat(70) + '\n')
await chatWithMemoryTool()
await testMemoryOperations()
console.log("\n" + "=".repeat(70) + "\n")
await chatWithMemoryTool()
}
if (import.meta.main) {
main().catch(console.error)
}
main().catch(console.error)
}

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