From 31d416b04a3e579eef4678cd9d4cccb23d180cc2 Mon Sep 17 00:00:00 2001 From: MaheshtheDev <38828053+MaheshtheDev@users.noreply.github.com> Date: Wed, 27 May 2026 00:59:32 +0000 Subject: [PATCH] feat(web): redesign login with split layout and context network panel (#1010) Consolidate login routes, add animated tool context visualization on the hero panel, and polish loading states across onboarding, settings, and workspace bootstrap. --- apps/web/app/(app)/onboarding/page.tsx | 63 ++- apps/web/app/(app)/settings/page.tsx | 173 ++++--- apps/web/app/(auth)/login/new/page.tsx | 547 +------------------- apps/web/app/(auth)/login/page.tsx | 564 ++++++++++++++++++++- apps/web/components/chat/input/actions.tsx | 16 +- apps/web/components/dashboard-view.tsx | 1 + apps/web/components/ensure-workspace.tsx | 38 +- apps/web/components/header.tsx | 2 +- apps/web/components/highlights-card.tsx | 13 +- apps/web/components/initial-header.tsx | 30 +- apps/web/components/login-tools-panel.tsx | 399 +++++++++++++++ apps/web/components/settings/account.tsx | 58 ++- apps/web/components/settings/billing.tsx | 5 + apps/web/components/user-profile-menu.tsx | 2 +- apps/web/globals.css | 317 ++++++++++++ bun.lock | 2 +- 16 files changed, 1541 insertions(+), 689 deletions(-) create mode 100644 apps/web/components/login-tools-panel.tsx diff --git a/apps/web/app/(app)/onboarding/page.tsx b/apps/web/app/(app)/onboarding/page.tsx index ad35867c..2c5eaf07 100644 --- a/apps/web/app/(app)/onboarding/page.tsx +++ b/apps/web/app/(app)/onboarding/page.tsx @@ -555,6 +555,7 @@ export default function OnboardingPage() { const fileRef = useRef(null) const pollingRef = useRef | null>(null) const skippingRef = useRef(false) + const [isSkipping, setIsSkipping] = useState(false) const [spotlightCategory, setSpotlightCategory] = useState("productivity") @@ -617,6 +618,7 @@ export default function OnboardingPage() { const handleSkip = useCallback(async () => { if (skippingRef.current) return skippingRef.current = true + setIsSkipping(true) try { await ensureOrg() const pendingPath = consumePendingConnectUrl() @@ -624,6 +626,7 @@ export default function OnboardingPage() { } catch (err) { console.error(err) skippingRef.current = false + setIsSkipping(false) } }, [ensureOrg, router]) @@ -798,9 +801,22 @@ export default function OnboardingPage() { @@ -870,34 +886,36 @@ export default function OnboardingPage() { /> - {isCheckingAccount && ( + {hasDetectedAccount && detected !== "resume" && ( - + {isCheckingAccount ? ( + + ) : canSubmit ? ( + + handleSubmit(detected as "x" | "linkedin") + } + className="rounded-xl size-8 flex items-center justify-center border-[0.5px] border-[#161F2C] hover:scale-[0.95] active:scale-[0.95] transition-transform cursor-pointer" + style={{ + background: + "linear-gradient(180deg, #0D121A -26.14%, #000 100%)", + }} + > + + + ) : null} )} - - {canSubmit && ( - handleSubmit(detected as "x" | "linkedin")} - className="absolute right-1 rounded-xl size-8 flex items-center justify-center border-[0.5px] border-[#161F2C] hover:scale-[0.95] active:scale-[0.95] transition-transform cursor-pointer" - style={{ - background: - "linear-gradient(180deg, #0D121A -26.14%, #000 100%)", - }} - > - - - )} @@ -929,9 +947,6 @@ export default function OnboardingPage() { {currentAccountLookup?.status === "error" && ( )} - {isCheckingAccount && ( - - )} {currentAccountLookup?.message ?? SOURCE_LABEL[detected as "x" | "linkedin"]} diff --git a/apps/web/app/(app)/settings/page.tsx b/apps/web/app/(app)/settings/page.tsx index f5d8c333..4d671977 100644 --- a/apps/web/app/(app)/settings/page.tsx +++ b/apps/web/app/(app)/settings/page.tsx @@ -298,92 +298,99 @@ export default function SettingsPage() { Settings
- {!isMobile && ( - { - if (canSwitchOrg) setOrgSwitcherOpen(open) - }} - > - - + + - - - - - {org?.name ?? "Personal"} - - - {canSwitchOrg && ( - - )} - - - a.name.localeCompare(b.name)) + .map((organization) => { + const isCurrent = organization.id === org?.id + const isSwitching = switchingOrgId === organization.id + const plan = resolveOrgPlan( + organization.id, + isCurrent, + currentPlan, + planByOrgId, + ) + return ( + + ) + })} + + + ) : ( +
- {[...(organizations ?? [])] - .sort((a, b) => a.name.localeCompare(b.name)) - .map((organization) => { - const isCurrent = organization.id === org?.id - const isSwitching = switchingOrgId === organization.id - const plan = resolveOrgPlan( - organization.id, - isCurrent, - currentPlan, - planByOrgId, - ) - return ( - - ) - })} - - - )} + + + + + {org?.name ?? "Personal"} + + +
+ ))}
@@ -639,6 +646,11 @@ export default function SettingsPage() { resetConfirmation !== confirmText || resetOrganization.isPending } + title={ + !confirmText || resetConfirmation !== confirmText + ? `Type "${confirmText || "your name"}" exactly to enable` + : undefined + } onClick={() => resetOrganization.mutate( { confirmation: confirmText }, @@ -719,6 +731,11 @@ export default function SettingsPage() { deleteEmailConfirm !== user?.email || deleteUserAccount.isPending } + title={ + deleteEmailConfirm !== user?.email + ? `Type ${user?.email ?? "your email"} exactly to confirm` + : undefined + } onClick={handleDeleteAccount} className="relative flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium cursor-pointer transition-opacity bg-[#290F0A] text-[#C73B1B] disabled:opacity-40 disabled:cursor-not-allowed hover:opacity-90" > diff --git a/apps/web/app/(auth)/login/new/page.tsx b/apps/web/app/(auth)/login/new/page.tsx index 086cd8ac..0fcc1c01 100644 --- a/apps/web/app/(auth)/login/new/page.tsx +++ b/apps/web/app/(auth)/login/new/page.tsx @@ -1,531 +1,26 @@ -"use client" +import { redirect } from "next/navigation" -import { signIn, useSession } from "@lib/auth" -import { usePostHog } from "@lib/posthog" -import { TextSeparator } from "@ui/components/text-separator" -import { ExternalAuthButton } from "@ui/button/external-auth" -import { Button } from "@ui/components/button" -import { Badge } from "@ui/components/badge" -import { LabeledInput } from "@ui/input/labeled-input" -import { HeadingH3Medium } from "@ui/text/heading/heading-h3-medium" -import { Label1Regular } from "@ui/text/label/label-1-regular" -import { Title1Bold } from "@ui/text/title/title-1-bold" -import { InitialHeader } from "@/components/initial-header" -import { useRouter, useSearchParams } from "next/navigation" -import { useState, useEffect } from "react" -import { motion } from "motion/react" -import { dmSansClassName } from "@/lib/fonts" -import { cn } from "@lib/utils" -import { Logo } from "@ui/assets/Logo" -import { resolveAuthRedirectUrl } from "@/lib/url-helpers" - -function isMcpOAuthAuthorizeContext(sp: Pick): boolean { - return sp.get("response_type") === "code" && Boolean(sp.get("client_id")) -} - -function buildMcpAuthorizeResumeUrl( - sp: Pick, +function serializeSearchParams( + sp: Record, ): string { - const backend = - process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai" - const p = new URLSearchParams(sp.toString()) - p.delete("redirect") - p.delete("error") - return `${backend}/api/auth/mcp/authorize?${p.toString()}` + const q = new URLSearchParams() + for (const [key, value] of Object.entries(sp)) { + if (value === undefined) continue + if (Array.isArray(value)) { + for (const v of value) q.append(key, v) + } else { + q.set(key, value) + } + } + return q.toString() } -function AnimatedGradientBackground() { - return ( -
- - - -
- ) -} - -function LoginCard({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ) -} - -export default function LoginPage() { - const [email, setEmail] = useState("") - const [submittedEmail, setSubmittedEmail] = useState(null) - const [isLoading, setIsLoading] = useState(false) - const [isLoadingEmail, setIsLoadingEmail] = useState(false) - const [error, setError] = useState(null) - const [lastUsedMethod, setLastUsedMethod] = useState(null) - const router = useRouter() - - const posthog = usePostHog() - - const params = useSearchParams() - const { data: sessionData, isPending: sessionPending } = useSession() - - const oauthQueryForResume = params.toString() - - useEffect(() => { - if (sessionPending) return - if (!sessionData?.session) return - const sp = new URLSearchParams(oauthQueryForResume) - if (isMcpOAuthAuthorizeContext(sp)) { - window.location.assign(buildMcpAuthorizeResumeUrl(sp)) - return - } - const redirectUrl = params.get("redirect") - if (redirectUrl) { - window.location.assign( - resolveAuthRedirectUrl(redirectUrl, window.location.origin).toString(), - ) - return - } - router.replace("/") - }, [ - sessionPending, - sessionData?.session, - oauthQueryForResume, - params, - router, - ]) - - // Get redirect URL from query params - const redirectUrl = params.get("redirect") - - // Create callback URL that includes redirect parameter if provided - const getCallbackURL = () => { - const origin = window.location.origin - - if (isMcpOAuthAuthorizeContext(params)) { - return buildMcpAuthorizeResumeUrl(params) - } - - const finalUrl = resolveAuthRedirectUrl(redirectUrl, origin) - - finalUrl.searchParams.set("extension-auth-success", "true") - return finalUrl.toString() - } - - // Load last used method from localStorage on mount - useEffect(() => { - const savedMethod = localStorage.getItem("supermemory-last-login-method") - setLastUsedMethod(savedMethod) - }, []) - - // Record the pending login method (will be committed after successful auth) - function setPendingLoginMethod(method: string) { - try { - localStorage.setItem("supermemory-pending-login-method", method) - localStorage.setItem( - "supermemory-pending-login-timestamp", - String(Date.now()), - ) - } catch {} - } - - function isNetworkError(error: unknown): boolean { - if (!(error instanceof Error)) return false - const message = error.message.toLowerCase() - return ( - message.includes("load failed") || - message.includes("networkerror") || - message.includes("failed to fetch") || - message.includes("network request failed") - ) - } - - function getErrorMessage(error: unknown): string { - if (isNetworkError(error)) { - return "Network error. Please check your connection and try again." - } - if (error instanceof Error) { - return error.message - } - return "An unexpected error occurred. Please try again." - } - - // If we land back on this page with an error, clear any pending marker - useEffect(() => { - if (params.get("error")) { - try { - localStorage.removeItem("supermemory-pending-login-method") - localStorage.removeItem("supermemory-pending-login-timestamp") - } catch {} - } - }, [params]) - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - setIsLoading(true) - setIsLoadingEmail(true) - setError(null) - - // Track login attempt - posthog.capture("login_attempt", { - method: "magic_link", - email_domain: email.split("@")[1] || "unknown", - }) - - const { error } = await signIn.magicLink({ - callbackURL: getCallbackURL(), - email, - }) - - if (error) { - console.error(error) - - // Track login failure - posthog.capture("login_failed", { - method: "magic_link", - error: error instanceof Error ? error.message : "Unknown error", - email_domain: email.split("@")[1] || "unknown", - is_network_error: isNetworkError(error), - }) - - setError(getErrorMessage(error)) - setIsLoading(false) - setIsLoadingEmail(false) - return - } - - setSubmittedEmail(email) - setPendingLoginMethod("magic_link") - posthog.capture("login_magic_link_sent", { - email_domain: email.split("@")[1] || "unknown", - }) - - setIsLoading(false) - setIsLoadingEmail(false) - } - - const handleSubmitToken = async (event: React.FormEvent) => { - event.preventDefault() - setIsLoading(true) - - const formData = new FormData(event.currentTarget) - const token = formData.get("token") as string - const callbackURL = getCallbackURL() - router.push( - `${process.env.NEXT_PUBLIC_BACKEND_URL}/api/auth/magic-link/verify?token=${token}&callbackURL=${encodeURIComponent(callbackURL)}`, - ) - } - - return ( -
- -
- -
-
-
- Never forget anything, anywhere -
-
with supermemory
-
- {submittedEmail ? ( - -
-
- - Almost there! - - - Click the magic link we've sent to{" "} - {submittedEmail}. - -
- - - -
- - - - -
-
- ) : ( - -
- {params.get("error") && ( -
- Error: {params.get("error")}. Please try again! -
- )} - -
- {process.env.NEXT_PUBLIC_HOST_ID === "supermemory" || - !process.env.NEXT_PUBLIC_GOOGLE_AUTH_ENABLED ? ( -
- - Google - - - - - - } - authProvider="Google" - className="w-full" - disabled={isLoading} - onClick={() => { - if (isLoading) return - setIsLoading(true) - posthog.capture("login_attempt", { - method: "social", - provider: "google", - }) - setPendingLoginMethod("google") - signIn - .social({ - callbackURL: getCallbackURL(), - provider: "google", - }) - .catch((err: unknown) => { - setError(getErrorMessage(err)) - }) - .finally(() => { - setIsLoading(false) - }) - }} - /> - {lastUsedMethod === "google" && ( -
- - Last used - -
- )} -
- ) : null} - {process.env.NEXT_PUBLIC_HOST_ID === "supermemory" || - !process.env.NEXT_PUBLIC_GITHUB_AUTH_ENABLED ? ( -
- - Github - - - - - - - - - - } - authProvider="Github" - className="w-full" - disabled={isLoading} - onClick={() => { - if (isLoading) return - setIsLoading(true) - posthog.capture("login_attempt", { - method: "social", - provider: "github", - }) - setPendingLoginMethod("github") - signIn - .social({ - callbackURL: getCallbackURL(), - provider: "github", - }) - .catch((err: unknown) => { - setError(getErrorMessage(err)) - }) - .finally(() => { - setIsLoading(false) - }) - }} - /> - {lastUsedMethod === "github" && ( -
- - Last used - -
- )} -
- ) : null} -
- - - -
-
- ) => { - setEmail(e.target.value) - error && setError(null) - }, - required: true, - value: email, - }} - inputType="email" - /> - -
- - {lastUsedMethod === "magic_link" && ( -
- - Last used - -
- )} -
- - - - By continuing, you agree to our{" "} - - - Terms - {" "} - and{" "} - - Privacy Policy - - . - - -
-
-
- )} -
-
-
- ) +export default async function Page({ + searchParams, +}: { + searchParams: Promise> +}) { + const sp = await searchParams + const query = serializeSearchParams(sp) + redirect(query ? `/login?${query}` : "/login") } diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx index a73f47ab..49fc56fc 100644 --- a/apps/web/app/(auth)/login/page.tsx +++ b/apps/web/app/(auth)/login/page.tsx @@ -1,26 +1,548 @@ -import { redirect } from "next/navigation" +"use client" -function serializeSearchParams( - sp: Record, +import { signIn, useSession } from "@lib/auth" +import { usePostHog } from "@lib/posthog" +import { TextSeparator } from "@ui/components/text-separator" +import { ExternalAuthButton } from "@ui/button/external-auth" +import { Button } from "@ui/components/button" +import { Badge } from "@ui/components/badge" +import { LabeledInput } from "@ui/input/labeled-input" +import { HeadingH3Medium } from "@ui/text/heading/heading-h3-medium" +import { Label1Regular } from "@ui/text/label/label-1-regular" +import { Title1Bold } from "@ui/text/title/title-1-bold" +import { InitialHeader } from "@/components/initial-header" +import { LoginToolsPanel } from "@/components/login-tools-panel" +import { useRouter, useSearchParams } from "next/navigation" +import { useState, useEffect } from "react" +import { motion } from "motion/react" +import { dmSansClassName } from "@/lib/fonts" +import { cn } from "@lib/utils" +import { Logo } from "@ui/assets/Logo" +import { resolveAuthRedirectUrl } from "@/lib/url-helpers" +import { Loader2 } from "lucide-react" + +function isMcpOAuthAuthorizeContext(sp: Pick): boolean { + return sp.get("response_type") === "code" && Boolean(sp.get("client_id")) +} + +function buildMcpAuthorizeResumeUrl( + sp: Pick, ): string { - const q = new URLSearchParams() - for (const [key, value] of Object.entries(sp)) { - if (value === undefined) continue - if (Array.isArray(value)) { - for (const v of value) q.append(key, v) - } else { - q.set(key, value) - } - } - return q.toString() + const backend = + process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai" + const p = new URLSearchParams(sp.toString()) + p.delete("redirect") + p.delete("error") + return `${backend}/api/auth/mcp/authorize?${p.toString()}` } -export default async function Page({ - searchParams, -}: { - searchParams: Promise> -}) { - const sp = await searchParams - const query = serializeSearchParams(sp) - redirect(query ? `/login/new?${query}` : "/login/new") +function LoginHeadline({ className }: { className?: string }) { + return ( +
+

+ Never forget anything, anywhere + with supermemory +

+

+ Save from Chrome, Notion, X — search it all in one place. +

+
+ ) +} + +function LastUsedBadge({ show }: { show: boolean }) { + if (!show) return null + return ( +
+ + Last used + +
+ ) +} +function LoginCard({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + +function LoginCardBody({ + children, + loadingMessage, +}: { + children: React.ReactNode + loadingMessage?: string | null +}) { + return ( +
+
{children}
+ {loadingMessage ? ( +
+ +

{loadingMessage}

+
+ ) : null} +
+ ) +} + +export default function LoginPage() { + const [email, setEmail] = useState("") + const [submittedEmail, setSubmittedEmail] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [isLoadingEmail, setIsLoadingEmail] = useState(false) + const [error, setError] = useState(null) + const [lastUsedMethod, setLastUsedMethod] = useState(null) + const router = useRouter() + + const posthog = usePostHog() + + const params = useSearchParams() + const { data: sessionData, isPending: sessionPending } = useSession() + + const oauthQueryForResume = params.toString() + const isRedirecting = !sessionPending && Boolean(sessionData?.session) + const isAuthResolving = sessionPending || isRedirecting + const loadingMessage = isAuthResolving + ? sessionPending + ? "Checking session…" + : "Redirecting…" + : isLoading + ? isLoadingEmail + ? "Sending login link…" + : "Redirecting…" + : null + + useEffect(() => { + if (sessionPending) return + if (!sessionData?.session) return + const sp = new URLSearchParams(oauthQueryForResume) + if (isMcpOAuthAuthorizeContext(sp)) { + window.location.assign(buildMcpAuthorizeResumeUrl(sp)) + return + } + const redirectUrl = params.get("redirect") + if (redirectUrl) { + window.location.assign( + resolveAuthRedirectUrl(redirectUrl, window.location.origin).toString(), + ) + return + } + router.replace("/") + }, [ + sessionPending, + sessionData?.session, + oauthQueryForResume, + params, + router, + ]) + + // Get redirect URL from query params + const redirectUrl = params.get("redirect") + + // Create callback URL that includes redirect parameter if provided + const getCallbackURL = () => { + const origin = window.location.origin + + if (isMcpOAuthAuthorizeContext(params)) { + return buildMcpAuthorizeResumeUrl(params) + } + + const finalUrl = resolveAuthRedirectUrl(redirectUrl, origin) + + finalUrl.searchParams.set("extension-auth-success", "true") + return finalUrl.toString() + } + + // Load last used method from localStorage on mount + useEffect(() => { + const savedMethod = localStorage.getItem("supermemory-last-login-method") + setLastUsedMethod(savedMethod) + }, []) + + // Record the pending login method (will be committed after successful auth) + function setPendingLoginMethod(method: string) { + try { + localStorage.setItem("supermemory-pending-login-method", method) + localStorage.setItem( + "supermemory-pending-login-timestamp", + String(Date.now()), + ) + } catch {} + } + + function isNetworkError(error: unknown): boolean { + if (!(error instanceof Error)) return false + const message = error.message.toLowerCase() + return ( + message.includes("load failed") || + message.includes("networkerror") || + message.includes("failed to fetch") || + message.includes("network request failed") + ) + } + + function getErrorMessage(error: unknown): string { + if (isNetworkError(error)) { + return "Network error. Please check your connection and try again." + } + if (error instanceof Error) { + return error.message + } + return "An unexpected error occurred. Please try again." + } + + // If we land back on this page with an error, clear any pending marker + useEffect(() => { + if (params.get("error")) { + try { + localStorage.removeItem("supermemory-pending-login-method") + localStorage.removeItem("supermemory-pending-login-timestamp") + } catch {} + } + }, [params]) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setIsLoading(true) + setIsLoadingEmail(true) + setError(null) + + // Track login attempt + posthog.capture("login_attempt", { + method: "magic_link", + email_domain: email.split("@")[1] || "unknown", + }) + + const { error } = await signIn.magicLink({ + callbackURL: getCallbackURL(), + email, + }) + + if (error) { + console.error(error) + + // Track login failure + posthog.capture("login_failed", { + method: "magic_link", + error: error instanceof Error ? error.message : "Unknown error", + email_domain: email.split("@")[1] || "unknown", + is_network_error: isNetworkError(error), + }) + + setError(getErrorMessage(error)) + setIsLoading(false) + setIsLoadingEmail(false) + return + } + + setSubmittedEmail(email) + setPendingLoginMethod("magic_link") + posthog.capture("login_magic_link_sent", { + email_domain: email.split("@")[1] || "unknown", + }) + + setIsLoading(false) + setIsLoadingEmail(false) + } + + const handleSubmitToken = async (event: React.FormEvent) => { + event.preventDefault() + setIsLoading(true) + + const formData = new FormData(event.currentTarget) + const token = formData.get("token") as string + const callbackURL = getCallbackURL() + router.push( + `${process.env.NEXT_PUBLIC_BACKEND_URL}/api/auth/magic-link/verify?token=${token}&callbackURL=${encodeURIComponent(callbackURL)}`, + ) + } + + return ( +
+
+
+
+
+
+ +
+ +
+
+ +
+
+
+ + + + {submittedEmail ? ( +
+
+ + Almost there! + + + Click the magic link we've sent to{" "} + + {submittedEmail} + + . + +
+ + + +
+ + + + +
+ ) : ( +
+ {params.get("error") && ( +
+ Error: {params.get("error")}. Please try again! +
+ )} + +
+ {process.env.NEXT_PUBLIC_HOST_ID === "supermemory" || + !process.env.NEXT_PUBLIC_GOOGLE_AUTH_ENABLED ? ( +
+ + + Google + + + + + + } + authProvider="Google" + className="w-full" + disabled={Boolean(loadingMessage)} + onClick={() => { + if (loadingMessage) return + setIsLoading(true) + posthog.capture("login_attempt", { + method: "social", + provider: "google", + }) + setPendingLoginMethod("google") + signIn + .social({ + callbackURL: getCallbackURL(), + provider: "google", + }) + .catch((err: unknown) => { + setError(getErrorMessage(err)) + setIsLoading(false) + }) + }} + /> +
+ ) : null} + {process.env.NEXT_PUBLIC_HOST_ID === "supermemory" || + !process.env.NEXT_PUBLIC_GITHUB_AUTH_ENABLED ? ( +
+ + + Github + + + + + + + + + + } + authProvider="Github" + className="w-full" + disabled={Boolean(loadingMessage)} + onClick={() => { + if (loadingMessage) return + setIsLoading(true) + posthog.capture("login_attempt", { + method: "social", + provider: "github", + }) + setPendingLoginMethod("github") + signIn + .social({ + callbackURL: getCallbackURL(), + provider: "github", + }) + .catch((err: unknown) => { + setError(getErrorMessage(err)) + setIsLoading(false) + }) + }} + /> +
+ ) : null} +
+ + + +
+
+ , + ) => { + setEmail(e.target.value) + error && setError(null) + }, + required: true, + value: email, + }} + inputType="email" + /> + +
+ + +
+ + + + By continuing, you agree to our{" "} + + + Terms + {" "} + and{" "} + + Privacy Policy + + . + + +
+
+ )} +
+
+
+
+
+
+ ) } diff --git a/apps/web/components/chat/input/actions.tsx b/apps/web/components/chat/input/actions.tsx index f8122f8e..b5850811 100644 --- a/apps/web/components/chat/input/actions.tsx +++ b/apps/web/components/chat/input/actions.tsx @@ -1,5 +1,6 @@ import { cn } from "@lib/utils" import { SquareIcon } from "lucide-react" +import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/components/tooltip" export function SendButton({ onClick, @@ -8,7 +9,7 @@ export function SendButton({ onClick: () => void disabled: boolean }) { - return ( + const button = ( ) + + if (disabled) { + return ( + + + {button} + + Type a message to send + + ) + } + + return button } export function StopButton({ onClick }: { onClick: () => void }) { diff --git a/apps/web/components/dashboard-view.tsx b/apps/web/components/dashboard-view.tsx index ad615e73..4194f827 100644 --- a/apps/web/components/dashboard-view.tsx +++ b/apps/web/components/dashboard-view.tsx @@ -1361,6 +1361,7 @@ export function DashboardView({ onChat={onHighlightsChat} onShowRelated={onHighlightsShowRelated} isLoading={isLoadingHighlights} + onAddMemory={() => onAddMemory("note")} />
diff --git a/apps/web/components/ensure-workspace.tsx b/apps/web/components/ensure-workspace.tsx index 7c6aed8b..ba7d02a3 100644 --- a/apps/web/components/ensure-workspace.tsx +++ b/apps/web/components/ensure-workspace.tsx @@ -3,6 +3,18 @@ import { useEffect } from "react" import { usePathname, useRouter, useSearchParams } from "next/navigation" import { useAuth } from "@lib/auth-context" +import { Loader2 } from "lucide-react" + +function WorkspaceLoadingShell() { + return ( +
+ +
+ ) +} export function EnsureWorkspace({ children }: { children: React.ReactNode }) { const pathname = usePathname() @@ -11,6 +23,7 @@ export function EnsureWorkspace({ children }: { children: React.ReactNode }) { const { session, organizations, isRestoring } = useAuth() const isMcpPublicPage = searchParams.get("view") === "mcp" + const isOnboarding = pathname.startsWith("/onboarding") useEffect(() => { if (isMcpPublicPage) return @@ -23,9 +36,30 @@ export function EnsureWorkspace({ children }: { children: React.ReactNode }) { } if (organizations === null) return if (organizations.length > 0) return - if (pathname.startsWith("/onboarding")) return + if (isOnboarding) return router.replace("/onboarding") - }, [session, organizations, isRestoring, pathname, router, isMcpPublicPage]) + }, [ + session, + organizations, + isRestoring, + isOnboarding, + router, + isMcpPublicPage, + ]) + + const showLoading = + !isMcpPublicPage && + (isRestoring || + (!session && !isRestoring) || + (session && organizations === null) || + (session && + organizations !== null && + organizations.length === 0 && + !isOnboarding)) + + if (showLoading) { + return + } return children } diff --git a/apps/web/components/header.tsx b/apps/web/components/header.tsx index 6847e5cd..fd81bf52 100644 --- a/apps/web/components/header.tsx +++ b/apps/web/components/header.tsx @@ -508,7 +508,7 @@ export function PublicHeader() { Sign in - +
-
+

Add some documents to see highlights here

+ {onAddMemory ? ( + + ) : null}
) diff --git a/apps/web/components/initial-header.tsx b/apps/web/components/initial-header.tsx index e7d3220f..831fb77c 100644 --- a/apps/web/components/initial-header.tsx +++ b/apps/web/components/initial-header.tsx @@ -6,6 +6,7 @@ import { useRouter } from "next/navigation" import { useOrgOnboarding } from "@hooks/use-org-onboarding" import { analytics } from "@/lib/analytics" import { consumePendingConnectUrl } from "@/lib/constants" +import { cn } from "@lib/utils" export function InitialHeader({ showUserSupermemory, @@ -28,10 +29,10 @@ export function InitialHeader({ } return ( -
-
- - {showUserSupermemory && ( +
+
+ + {showUserSupermemory ? (

{userName} @@ -40,9 +41,13 @@ export function InitialHeader({ supermemory

+ ) : ( +

+ supermemory +

)}
-
+
{showSkipOnboarding && !isLoading && (
diff --git a/apps/web/components/login-tools-panel.tsx b/apps/web/components/login-tools-panel.tsx new file mode 100644 index 00000000..8b6f1da1 --- /dev/null +++ b/apps/web/components/login-tools-panel.tsx @@ -0,0 +1,399 @@ +"use client" + +import Image from "next/image" +import { useEffect, useState } from "react" +import { motion } from "motion/react" +import { cn } from "@lib/utils" +import { dmSansClassName } from "@/lib/fonts" +import { ChromeIcon, RaycastIcon } from "@/components/integration-icons" +import { + ClaudeDesktopIcon, + GoogleDrive, + MCPIcon, + Notion, +} from "@ui/assets/icons" +import { Logo } from "@ui/assets/Logo" +import NovaOrb from "@/components/nova/nova-orb" + +type ToolNode = { + id: string + name: string + x: number + y: number + icon?: React.ComponentType<{ className?: string }> + iconSrc?: string +} + +type ContextConnection = { + from: ToolNode + to: ToolNode +} + +type ContextPhase = "idle" | "capture" | "hold" | "recall" + +const CENTER = { x: 50, y: 50 } +const IN_MS = 1100 +const HOLD_MS = 700 +const OUT_MS = 1100 +const TOTAL_MS = IN_MS + HOLD_MS + OUT_MS + +const TOOL_NODES: ToolNode[] = [ + { id: "chrome", name: "Chrome", x: 14, y: 20, icon: ChromeIcon }, + { id: "notion", name: "Notion", x: 84, y: 16, icon: Notion }, + { id: "drive", name: "Google Drive", x: 10, y: 52, icon: GoogleDrive }, + { id: "claude", name: "Claude", x: 90, y: 44, icon: ClaudeDesktopIcon }, + { id: "raycast", name: "Raycast", x: 76, y: 76, icon: RaycastIcon }, + { id: "mcp", name: "MCP", x: 22, y: 84, icon: MCPIcon }, + { + id: "claude-code", + name: "Claude Code", + x: 20, + y: 36, + iconSrc: "/images/plugins/claude-code.svg", + }, + { + id: "codex", + name: "Codex", + x: 92, + y: 28, + iconSrc: "/images/plugins/codex.png", + }, + { + id: "opencode", + name: "OpenCode", + x: 58, + y: 10, + iconSrc: "/images/plugins/opencode.svg", + }, + { + id: "hermes", + name: "Hermes", + x: 36, + y: 90, + iconSrc: "/images/plugins/hermes.svg", + }, + { + id: "openclaw", + name: "OpenClaw", + x: 68, + y: 68, + iconSrc: "/images/plugins/openclaw.svg", + }, +] + +const CONTEXT_FLOWS: [string, string][] = [ + ["chrome", "claude"], + ["notion", "raycast"], + ["drive", "claude-code"], + ["opencode", "codex"], + ["claude-code", "mcp"], + ["hermes", "openclaw"], + ["claude", "mcp"], + ["notion", "claude"], +] + +function nodeById(id: string) { + return TOOL_NODES.find((node) => node.id === id) +} + +function pickContextFlow(): ContextConnection | null { + const flow = CONTEXT_FLOWS[Math.floor(Math.random() * CONTEXT_FLOWS.length)] + if (!flow) return null + const [fromId, toId] = flow + const from = nodeById(fromId) + const to = nodeById(toId) + if (!from || !to) return null + return { from, to } +} + +function ToolNodeIcon({ + node, + role, +}: { + node: ToolNode + role?: "source" | "destination" +}) { + const Icon = node.icon + + return ( +
+
+ {Icon ? ( + + ) : node.iconSrc ? ( + + ) : null} +
+ + {node.name} + +
+ ) +} + +function MemoryChip() { + return ( +
+
+ + + +
+
+ ) +} + +function AnimatedContextFlow({ + connection, + phase, +}: { + connection: ContextConnection + phase: ContextPhase +}) { + const { from, to } = connection + const dIn = `M ${from.x} ${from.y} L ${CENTER.x} ${CENTER.y}` + const dOut = `M ${CENTER.x} ${CENTER.y} L ${to.x} ${to.y}` + + const chipLeft = + phase === "recall" + ? [`${CENTER.x}%`, `${to.x}%`] + : phase === "hold" + ? `${CENTER.x}%` + : [`${from.x}%`, `${CENTER.x}%`] + + const chipTop = + phase === "recall" + ? [`${CENTER.y}%`, `${to.y}%`] + : phase === "hold" + ? `${CENTER.y}%` + : [`${from.y}%`, `${CENTER.y}%`] + + return ( +
+ + + {phase !== "idle" && ( + + + + )} +
+ ) +} + +function ToolsContextNetwork() { + const [connection, setConnection] = useState(null) + const [pulseId, setPulseId] = useState(0) + const [phase, setPhase] = useState("idle") + + useEffect(() => { + let cancelled = false + let pulseTimeout: ReturnType + + const runPulse = () => { + if (cancelled) return + const next = pickContextFlow() + if (!next) return + setConnection(next) + setPulseId((id) => id + 1) + pulseTimeout = setTimeout(runPulse, TOTAL_MS + 900 + Math.random() * 500) + } + + runPulse() + return () => { + cancelled = true + clearTimeout(pulseTimeout) + } + }, []) + + useEffect(() => { + if (!connection) return + + setPhase("capture") + const holdTimer = setTimeout(() => setPhase("hold"), IN_MS) + const recallTimer = setTimeout(() => setPhase("recall"), IN_MS + HOLD_MS) + const idleTimer = setTimeout(() => setPhase("idle"), TOTAL_MS) + + return () => { + clearTimeout(holdTimer) + clearTimeout(recallTimer) + clearTimeout(idleTimer) + } + }, [connection]) + + const sourceRole = (nodeId: string) => + connection && + connection.from.id === nodeId && + (phase === "capture" || phase === "hold") + ? ("source" as const) + : undefined + + const destRole = (nodeId: string) => + connection && connection.to.id === nodeId && phase === "recall" + ? ("destination" as const) + : undefined + + return ( +
+ {connection && phase !== "idle" ? ( + + ) : null} + + {TOOL_NODES.map((node) => ( + + ))} + +
+ + +
+ +
+
+
+
+ ) +} + +function LoginPanelBackground() { + return ( + <> +
+
+
+
+ + ) +} + +export function LoginToolsPanel() { + return ( + + ) +} diff --git a/apps/web/components/settings/account.tsx b/apps/web/components/settings/account.tsx index d43cd604..155659b5 100644 --- a/apps/web/components/settings/account.tsx +++ b/apps/web/components/settings/account.tsx @@ -438,35 +438,25 @@ export default function Account() { > Organization

- { - if (canSwitchOrg) setOrgMenuOpen(open) - }} - > - - + - {org?.name ?? "Personal"} - - {canSwitchOrg && ( + + {org?.name ?? "Personal"} + - )} - - {canSwitchOrg && ( + - )} - + + ) : ( + + {org?.name ?? "Personal"} + + )}