diff --git a/apps/browser-extension/entrypoints/content/chatgpt.ts b/apps/browser-extension/entrypoints/content/chatgpt.ts index 51a04736..7c3d28aa 100644 --- a/apps/browser-extension/entrypoints/content/chatgpt.ts +++ b/apps/browser-extension/entrypoints/content/chatgpt.ts @@ -159,7 +159,7 @@ async function getRelatedMemoriesForChatGPT(actionSource: string) { if (response?.success && response?.data) { const promptElement = document.getElementById("prompt-textarea") if (promptElement) { - promptElement.dataset.supermemories = `
Supermemories of user (only for the reference): ${response.data}
` + promptElement.dataset.supermemories = `\n\nSupermemories of user (only for the reference): ${response.data}` console.log( "Prompt element dataset:", promptElement.dataset.supermemories, @@ -471,7 +471,7 @@ function updateChatGPTIconFeedback( const promptElement = document.getElementById("prompt-textarea") if (promptElement) { - promptElement.dataset.supermemories = `
Supermemories of user (only for the reference): ${updatedMemories}
` + promptElement.dataset.supermemories = `\n\nSupermemories of user (only for the reference): ${updatedMemories}` } content @@ -647,7 +647,7 @@ function setupChatGPTPromptCapture() { promptTextarea && !promptContent.includes("Supermemories of user") ) { - promptTextarea.innerHTML = `${promptTextarea.innerHTML} ${storedMemories}` + promptTextarea.appendChild(document.createTextNode(storedMemories)) promptContent = promptTextarea.textContent || "" } diff --git a/apps/browser-extension/entrypoints/content/claude.ts b/apps/browser-extension/entrypoints/content/claude.ts index 01016a40..d124c84a 100644 --- a/apps/browser-extension/entrypoints/content/claude.ts +++ b/apps/browser-extension/entrypoints/content/claude.ts @@ -230,7 +230,7 @@ async function getRelatedMemoriesForClaude(actionSource: string) { ) as HTMLElement if (textareaElement) { - textareaElement.dataset.supermemories = `
Supermemories of user (only for the reference): ${response.data}
` + textareaElement.dataset.supermemories = `\n\nSupermemories of user (only for the reference): ${response.data}` console.log( "Text element dataset:", textareaElement.dataset.supermemories, @@ -442,7 +442,7 @@ function updateClaudeIconFeedback( 'div[contenteditable="true"]', ) as HTMLElement if (textareaElement) { - textareaElement.dataset.supermemories = `
Supermemories of user (only for the reference): ${updatedMemories}
` + textareaElement.dataset.supermemories = `\n\nSupermemories of user (only for the reference): ${updatedMemories}` } content @@ -520,7 +520,7 @@ function setupClaudePromptCapture() { contentEditableDiv && !promptContent.includes("Supermemories of user") ) { - contentEditableDiv.innerHTML = `${contentEditableDiv.innerHTML} ${storedMemories}` + contentEditableDiv.appendChild(document.createTextNode(storedMemories)) promptContent = contentEditableDiv.textContent || contentEditableDiv.innerText || "" } diff --git a/apps/browser-extension/entrypoints/content/t3.ts b/apps/browser-extension/entrypoints/content/t3.ts index 4d284a35..e0b97e3f 100644 --- a/apps/browser-extension/entrypoints/content/t3.ts +++ b/apps/browser-extension/entrypoints/content/t3.ts @@ -238,13 +238,8 @@ async function getRelatedMemoriesForT3(actionSource: string) { } if (textareaElement) { - if (textareaElement.tagName === "TEXTAREA") { - ;(textareaElement as HTMLTextAreaElement).dataset.supermemories = - `
Supermemories of user (only for the reference): ${response.data}
` - } else { - ;(textareaElement as HTMLElement).dataset.supermemories = - `
Supermemories of user (only for the reference): ${response.data}
` - } + textareaElement.dataset.supermemories = + `\n\nSupermemories of user (only for the reference): ${response.data}` iconElement.dataset.memoriesData = response.data @@ -450,7 +445,7 @@ function updateT3IconFeedback( (document.querySelector("textarea") as HTMLTextAreaElement) || (document.querySelector('div[contenteditable="true"]') as HTMLElement) if (textareaElement) { - textareaElement.dataset.supermemories = `
Supermemories of user (only for the reference): ${updatedMemories}
` + textareaElement.dataset.supermemories = `\n\nSupermemories of user (only for the reference): ${updatedMemories}` } content @@ -537,7 +532,7 @@ function setupT3PromptCapture() { `${promptContent} ${storedMemories}` promptContent = (textareaElement as HTMLTextAreaElement).value } else { - textareaElement.innerHTML = `${textareaElement.innerHTML} ${storedMemories}` + textareaElement.appendChild(document.createTextNode(storedMemories)) promptContent = textareaElement.textContent || textareaElement.innerText || "" } diff --git a/apps/web/app/(app)/onboarding/page.tsx b/apps/web/app/(app)/onboarding/page.tsx index 07cb66c2..ad35867c 100644 --- a/apps/web/app/(app)/onboarding/page.tsx +++ b/apps/web/app/(app)/onboarding/page.tsx @@ -6,7 +6,10 @@ import { useCallback, useEffect, useMemo, + type Dispatch, + type RefObject, type ReactNode, + type SetStateAction, } from "react" import { useRouter } from "next/navigation" import { useAuth } from "@lib/auth-context" @@ -30,12 +33,25 @@ import { RaycastIcon, } from "@/components/integration-icons" import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons" -import { Sparkles, ChevronLeft, ChevronRight } from "lucide-react" +import { + Sparkles, + ChevronLeft, + ChevronRight, + AlertCircle, + CheckCircle2, + Loader2, +} from "lucide-react" import { analytics } from "@/lib/analytics" import { consumePendingConnectUrl } from "@/lib/constants" type DetectedSource = "x" | "linkedin" | "resume" | null type Status = "idle" | "processing" | "done" | "error" +type AccountLookupStatus = "checking" | "found" | "not_found" | "error" +type AccountLookup = { + source: "x" | "linkedin" + status: AccountLookupStatus + message: string +} type DocStatus = | "unknown" | "queued" @@ -54,7 +70,7 @@ function XIcon({ className }: { className?: string }) { fill="currentColor" aria-hidden="true" > - + ) } @@ -67,7 +83,7 @@ function LinkedInIcon({ className }: { className?: string }) { fill="currentColor" aria-hidden="true" > - + ) } @@ -77,7 +93,7 @@ function SubmitArrow() { Submit @@ -122,8 +138,13 @@ const SOURCE_ICON: Record< } const SOURCE_LABEL: Record<"x" | "linkedin", string> = { - x: "X profile detected — press Enter to continue", - linkedin: "LinkedIn profile detected — press Enter to continue", + x: "X profile detected - checking account", + linkedin: "LinkedIn profile detected - checking account", +} + +const SOURCE_NAME: Record<"x" | "linkedin", string> = { + x: "X", + linkedin: "LinkedIn", } type SpotlightItem = { @@ -353,6 +374,167 @@ function buildSpotlightCatalog( } } +function isAccountSource(source: DetectedSource): source is "x" | "linkedin" { + return source === "x" || source === "linkedin" +} + +function useSpotlightAutoRotation( + status: Status, + pauseSpotlight: boolean, + setSpotlightCategory: Dispatch>, +) { + useEffect(() => { + if (status !== "processing") return + if (pauseSpotlight) return + const n = SPOTLIGHT_CATEGORY_ORDER.length + if (n <= 1) return + const t = setInterval(() => { + setSpotlightCategory((cur) => { + const i = SPOTLIGHT_CATEGORY_ORDER.indexOf(cur) + const from = i >= 0 ? i : 0 + const next = (from + 1) % n + return SPOTLIGHT_CATEGORY_ORDER[next] ?? cur + }) + }, 8000) + return () => clearInterval(t) + }, [status, pauseSpotlight, setSpotlightCategory]) +} + +function useInitialInputFocus(inputRef: RefObject) { + useEffect(() => { + const t = setTimeout(() => inputRef.current?.focus(), 500) + return () => clearTimeout(t) + }, [inputRef]) +} + +function useAccountLookup({ + detected, + status, + value, +}: { + detected: DetectedSource + status: Status + value: string +}) { + const [accountLookup, setAccountLookup] = useState(null) + + useEffect(() => { + if (status !== "idle") return + + const source = isAccountSource(detected) ? detected : null + const trimmedValue = value.trim() + + if (!source || !trimmedValue) { + setAccountLookup(null) + return + } + + const controller = new AbortController() + setAccountLookup({ + source, + status: "checking", + message: SOURCE_LABEL[source], + }) + + const timeout = setTimeout(async () => { + try { + const params = new URLSearchParams({ + source, + value: trimmedValue, + }) + const response = await fetch( + `/api/onboarding/account-status?${params.toString()}`, + { signal: controller.signal }, + ) + const data: { + found?: boolean + handle?: string + reason?: string + verified?: boolean + } = await response.json().catch(() => ({})) + + if (controller.signal.aborted) return + + if (response.ok && data.found === true) { + const account = + source === "x" && data.handle ? ` @${data.handle}` : "" + setAccountLookup({ + source, + status: "found", + message: `${SOURCE_NAME[source]} account${account} found - press Enter to continue`, + }) + return + } + + if ( + (response.ok && data.found === false) || + data.reason === "invalid" + ) { + setAccountLookup({ + source, + status: "not_found", + message: `${SOURCE_NAME[source]} account not found. Check the link and try again.`, + }) + return + } + + setAccountLookup({ + source, + status: "error", + message: `Could not verify ${SOURCE_NAME[source]} account. You can still continue.`, + }) + } catch (err) { + if (controller.signal.aborted) return + console.error(err) + setAccountLookup({ + source, + status: "error", + message: `Could not verify ${SOURCE_NAME[source]} account. You can still continue.`, + }) + } + }, 450) + + return () => { + clearTimeout(timeout) + controller.abort() + } + }, [detected, status, value]) + + return accountLookup +} + +function usePollingCleanup( + pollingRef: RefObject | null>, +) { + useEffect(() => { + return () => { + if (pollingRef.current) clearInterval(pollingRef.current) + } + }, [pollingRef]) +} + +function useDoneAnimation( + status: Status, + setStampLanded: Dispatch>, + setVisibleSnippets: Dispatch>, +) { + useEffect(() => { + if (status !== "done") return + setStampLanded(false) + setVisibleSnippets(0) + const t1 = setTimeout(() => setStampLanded(true), 400) + const t2 = setTimeout(() => setVisibleSnippets(1), 900) + const t3 = setTimeout(() => setVisibleSnippets(2), 1200) + const t4 = setTimeout(() => setVisibleSnippets(3), 1500) + return () => { + clearTimeout(t1) + clearTimeout(t2) + clearTimeout(t3) + clearTimeout(t4) + } + }, [status, setStampLanded, setVisibleSnippets]) +} + export default function OnboardingPage() { const router = useRouter() const { user, organizations, refetchOrganizations, setActiveOrg } = useAuth() @@ -402,48 +584,11 @@ export default function OnboardingPage() { [spotlightCategory], ) - useEffect(() => { - if (status !== "processing") return - if (pauseSpotlight) return - const n = SPOTLIGHT_CATEGORY_ORDER.length - if (n <= 1) return - const t = setInterval(() => { - setSpotlightCategory((cur) => { - const i = SPOTLIGHT_CATEGORY_ORDER.indexOf(cur) - const from = i >= 0 ? i : 0 - const next = (from + 1) % n - return SPOTLIGHT_CATEGORY_ORDER[next] ?? cur - }) - }, 8000) - return () => clearInterval(t) - }, [status, pauseSpotlight]) - - useEffect(() => { - const t = setTimeout(() => inputRef.current?.focus(), 500) - return () => clearTimeout(t) - }, []) - - useEffect(() => { - return () => { - if (pollingRef.current) clearInterval(pollingRef.current) - } - }, []) - - useEffect(() => { - if (status !== "done") return - setStampLanded(false) - setVisibleSnippets(0) - const t1 = setTimeout(() => setStampLanded(true), 400) - const t2 = setTimeout(() => setVisibleSnippets(1), 900) - const t3 = setTimeout(() => setVisibleSnippets(2), 1200) - const t4 = setTimeout(() => setVisibleSnippets(3), 1500) - return () => { - clearTimeout(t1) - clearTimeout(t2) - clearTimeout(t3) - clearTimeout(t4) - } - }, [status]) + useSpotlightAutoRotation(status, pauseSpotlight, setSpotlightCategory) + useInitialInputFocus(inputRef) + const accountLookup = useAccountLookup({ detected, status, value }) + usePollingCleanup(pollingRef) + useDoneAnimation(status, setStampLanded, setVisibleSnippets) const handleChange = (v: string) => { setValue(v) @@ -605,7 +750,18 @@ export default function OnboardingPage() { } } - const canSubmit = detected && detected !== "resume" + const hasDetectedAccount = detected === "x" || detected === "linkedin" + const currentAccountLookup = + accountLookup?.source === detected ? accountLookup : null + const isCheckingAccount = + hasDetectedAccount && + (!currentAccountLookup || currentAccountLookup.status === "checking") + const canSubmit = Boolean( + hasDetectedAccount && + currentAccountLookup && + currentAccountLookup.status !== "checking" && + currentAccountLookup.status !== "not_found", + ) return ( // biome-ignore lint/a11y/noStaticElementInteractions: full-surface drag-and-drop for resume PDF @@ -713,6 +869,20 @@ export default function OnboardingPage() { )} /> + + {isCheckingAccount && ( + + + + )} + + {canSubmit && ( - {SOURCE_LABEL[detected as "x" | "linkedin"]} + {currentAccountLookup?.status === "found" && ( + + )} + {currentAccountLookup?.status === "not_found" && ( + + )} + {currentAccountLookup?.status === "error" && ( + + )} + {isCheckingAccount && ( + + )} + + {currentAccountLookup?.message ?? + SOURCE_LABEL[detected as "x" | "linkedin"]} + )} @@ -817,7 +1013,7 @@ export default function OnboardingPage() { Finishing your first save

- Most finish in under a minute. Below is optional — ways to add + Most finish in under a minute. Below is optional: ways to add more later.

@@ -956,7 +1152,7 @@ export default function OnboardingPage() {

Your first save is ready. When you want more, use Integrations - for browser, phone, editor, and AI tools — all in one place. + for browser, phone, editor, and AI tools, all in one place.

diff --git a/apps/web/app/(app)/page.tsx b/apps/web/app/(app)/page.tsx index e7d7caff..1f30081a 100644 --- a/apps/web/app/(app)/page.tsx +++ b/apps/web/app/(app)/page.tsx @@ -78,7 +78,7 @@ const GRADIENT_TOP_WIDTH_MAX = 1440 function gradientTopPositionForWidth(width: number) { const minW = 320 const pctWide = 15 - const pctNarrow = 70 + const pctNarrow = 55 const w = Math.min(GRADIENT_TOP_WIDTH_MAX, Math.max(minW, width)) const t = (w - minW) / (GRADIENT_TOP_WIDTH_MAX - minW) const eased = t * t @@ -402,6 +402,18 @@ export default function NewPage() { [setDocId], ) + // Separate from handleOpenDocument because the graph view only has a document ID, + // not the full document object. The modal will fetch the document via the docId + // query param, so there may be a brief loading state (unlike handleOpenDocument + // which pre-populates via setSelectedDocument). + const handleOpenDocumentById = useCallback( + (documentId: string) => { + analytics.documentModalOpened({ document_id: documentId }) + setDocId(documentId) + }, + [setDocId], + ) + const handleQuickNoteSave = useCallback( (content: string) => { if (content.trim()) { @@ -515,12 +527,11 @@ export default function NewPage() { const gradientTopPosition = gradientTopPositionForWidth(viewportWidth) const isChatView = viewMode === "chat" - const isGraphMode = viewMode === "graph" && !isMobile - const isMemoriesDesktop = viewMode === "list" && !isMobile - const isHomeDesktop = viewMode === "dashboard" && !isMobile - const showNovaBackdrop = isGraphMode || isMemoriesDesktop || isHomeDesktop + const showNovaBackdrop = + viewMode === "graph" || viewMode === "list" || viewMode === "dashboard" const isDashboardShell = viewMode === "dashboard" || (viewMode === "graph" && isMobile) + const isGraphMode = viewMode === "graph" return ( @@ -629,9 +640,9 @@ export default function NewPage() { void setViewMode("integrations")} /> - ) : viewMode === "graph" && !isMobile ? ( + ) : viewMode === "graph" ? (
- +
) : viewMode === "list" ? (
- - Graph view is available on desktop. - {" "} - Use a larger screen for the full graph, or keep - working from this home view. -
- ) : undefined - } + headerNotice={undefined} highlights={highlightsData?.highlights ?? []} isLoadingHighlights={isLoadingHighlights} onAddMemory={handleAddMemory} @@ -712,12 +710,7 @@ export default function NewPage() { {isDashboardShell && ( -
+
diff --git a/apps/web/app/(app)/settings/page.tsx b/apps/web/app/(app)/settings/page.tsx index 4f092cbb..ddf8a524 100644 --- a/apps/web/app/(app)/settings/page.tsx +++ b/apps/web/app/(app)/settings/page.tsx @@ -251,10 +251,14 @@ export default function SettingsPage() { }, []) const headerDisplayName = - user?.displayUsername || localStorageUsername || user?.name || "" + user?.displayUsername || + localStorageUsername || + user?.name || + user?.email?.split("@")[0] || + "" const headerPossessive = headerDisplayName ? `${headerDisplayName.split(" ")[0]}'s` - : "" + : "Your" return (
@@ -299,12 +303,12 @@ export default function SettingsPage() {
-
+
{!isMobile && ( - - + + )}