From 3b24bf2fb2cd2e06741b0b8ebf01f4a478fcfb27 Mon Sep 17 00:00:00 2001 From: Mahesh Sanikommu Date: Fri, 29 May 2026 08:17:17 -0700 Subject: [PATCH 1/4] fix(web): make Nova chat mobile-native instead of full-screen takeover (#1021) --- apps/web/app/(app)/page.tsx | 2 +- apps/web/app/layout.tsx | 1 + apps/web/components/chat/index.tsx | 54 +++++++++--------------------- 3 files changed, 18 insertions(+), 39 deletions(-) diff --git a/apps/web/app/(app)/page.tsx b/apps/web/app/(app)/page.tsx index c177ec1e..120bd2b9 100644 --- a/apps/web/app/(app)/page.tsx +++ b/apps/web/app/(app)/page.tsx @@ -562,7 +562,7 @@ export default function NewPage() { const isDashboardShell = viewMode === "dashboard" || (viewMode === "graph" && isMobile) const isGraphMode = viewMode === "graph" - const showBottomNav = isMobile && !isChatView && !!session + const showBottomNav = isMobile && !!session return ( diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 5218f989..f044e533 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -38,6 +38,7 @@ export const viewport: Viewport = { width: "device-width", initialScale: 1, viewportFit: "cover", + interactiveWidget: "resizes-content", } export default function RootLayout({ diff --git a/apps/web/components/chat/index.tsx b/apps/web/components/chat/index.tsx index c8ebb1e1..af21ddfa 100644 --- a/apps/web/components/chat/index.tsx +++ b/apps/web/components/chat/index.tsx @@ -19,7 +19,6 @@ import { } from "@ui/components/sheet" import { ScrollArea } from "@ui/components/scroll-area" import { - ArrowLeft, Check, ChevronDownIcon, HistoryIcon, @@ -97,7 +96,7 @@ export function ChatLaunchFab({ export function ChatSidebar({ isChatOpen, - setIsChatOpen, + setIsChatOpen: _setIsChatOpen, queuedMessage, queuedHighlightContent, onConsumeQueuedMessage, @@ -781,8 +780,7 @@ export function ChatSidebar({ const isStackedInput = layout === "page" const showHeaderRow = !isPageDesktop || isMobile || !isStackedInput const isResponding = status === "submitted" || status === "streaming" - const showInputStatusStrip = - !isStackedInput || isResponding || messages.length > 0 + const showInputStatusStrip = !isStackedInput const chatHistorySheet = (
- {layout === "page" && isMobile && ( - - )} {!isStackedInput && ( <> 0 ? cn( "flex flex-col space-y-3 min-h-full justify-end", - isPageDesktop ? "pt-2" : "pt-14", + isPageDesktop || isMobile ? "pt-2" : "pt-14", ) : "" } @@ -1142,7 +1128,9 @@ export function ChatSidebar({ className={cn( "shrink-0", isStackedInput && - "pb-[max(1.25rem,calc(env(safe-area-inset-bottom)+1rem))] md:pb-6", + (isMobile + ? "px-4 pb-2" + : "px-4 pb-[max(1.25rem,calc(env(safe-area-inset-bottom)+1rem))] md:pb-6"), )} > From 6fb42ad0e0997761ebf3fa8e9d16c21034e5457e Mon Sep 17 00:00:00 2001 From: Vedant Mahajan Date: Fri, 29 May 2026 23:46:27 +0530 Subject: [PATCH 2/4] Remove redundant credits section (#1019) Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.5 --- apps/web/components/settings/billing.tsx | 526 ++++++++----------- apps/web/components/settings/org-context.tsx | 27 +- 2 files changed, 236 insertions(+), 317 deletions(-) diff --git a/apps/web/components/settings/billing.tsx b/apps/web/components/settings/billing.tsx index 0a650a15..b03a6659 100644 --- a/apps/web/components/settings/billing.tsx +++ b/apps/web/components/settings/billing.tsx @@ -31,6 +31,9 @@ const CREDIT_FEATURE_ID = "usd_credits" const TOP_UP_PLAN_ID = "credits_topup" const TOP_UP_AMOUNTS = [10, 25, 50, 100] as const +const SURFACE_SHADOW = + "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" + type BillingInvoice = { planIds?: string[] stripeId: string @@ -694,322 +697,237 @@ export default function Billing() { -
- Credits - - -
-
-

- Usage this period +

+ +
+
+

+ Buy Credits

-
- - {planUsagePct < 1 && planUsagePct > 0 - ? "< 1" - : Math.round(planUsagePct)} - % - - of monthly usage - - {daysRemaining !== null - ? `· resets in ${daysRemaining} day${daysRemaining !== 1 ? "s" : ""}` - : "· resets with your billing cycle"} - -
-
-
-
-

- {planUsagePct > 0 - ? `${formatUsd(usdSpent)} used this period` - : "No usage yet this period"} +

+ Add USD to your balance for metered usage.

- -
- - - - - -
-
-

- Buy Credits -

-

- Add USD to your balance for metered usage. -

-
- - - -
- -
-
-

- Choose an amount -

- { - setTopUpAmount(value) - setCustomTopUpAmount("") - }} - disabled={topUpPendingAmount !== null} - /> -
- - - setCustomTopUpAmount(event.target.value) - } - placeholder="e.g. 75" - type="number" - value={customTopUpAmount} - className="h-11 rounded-[10px] border border-white/10 bg-[#080B10] px-3 text-[14px] text-[#FAFAFA] outline-none placeholder:text-[#737373] focus:border-[#0054AD]" - /> -
-
- -
- -
-
-

- Auto reload -

- - {autoTopUpEnabled ? "on" : "off"} - -
- -
-

- Auto reload is{" "} - {autoTopUpEnabled ? "enabled" : "disabled"} -

- -
- - {!hasPaymentMethod && !activeAutoTopUp?.enabled ? ( -

- Save a card in Manage Billing to enable automatic - reloads. -

- ) : null} - -
-
- - { - const value = Number.parseFloat( - event.target.value, - ) - setAutoTopUpThreshold( - Number.isFinite(value) ? value : 0, - ) - }} - type="number" - value={ - Number.isFinite(autoTopUpThreshold) - ? autoTopUpThreshold - : "" - } - className="h-10 rounded-[8px] border border-white/10 bg-[#080B10] px-3 text-[13px] text-[#FAFAFA] outline-none focus:border-[#0054AD] disabled:opacity-60" - /> -
-
- - { - const value = Number.parseFloat( - event.target.value, - ) - setAutoTopUpAmount( - Number.isFinite(value) ? value : 0, - ) - }} - type="number" - value={ - Number.isFinite(autoTopUpAmount) - ? autoTopUpAmount - : "" - } - className="h-10 rounded-[8px] border border-white/10 bg-[#080B10] px-3 text-[13px] text-[#FAFAFA] outline-none focus:border-[#0054AD] disabled:opacity-60" - /> -
-
- -
-
-
- - - - {!isAdmin ? ( -

- Only owners/admins can purchase credits. -

- ) : null} -
- -
+ -
+
- - {hasPaidPlan ? ( +
+
+

+ Choose an amount +

+ { + setTopUpAmount(value) + setCustomTopUpAmount("") + }} + disabled={topUpPendingAmount !== null} + /> +
+ + setCustomTopUpAmount(event.target.value)} + placeholder="e.g. 75" + type="number" + value={customTopUpAmount} + className="h-11 rounded-[10px] border border-white/10 bg-[#080B10] px-3 text-[14px] text-[#FAFAFA] outline-none placeholder:text-[#737373] focus:border-[#0054AD]" + /> +
+
+ +
+ +
+
+

+ Auto reload +

+ + {autoTopUpEnabled ? "on" : "off"} + +
+ +
+

+ Auto reload is {autoTopUpEnabled ? "enabled" : "disabled"} +

+ +
+ + {!hasPaymentMethod && !activeAutoTopUp?.enabled ? ( +

+ Save a card in Manage Billing to enable automatic reloads. +

+ ) : null} + +
+
+ + { + const value = Number.parseFloat(event.target.value) + setAutoTopUpThreshold(Number.isFinite(value) ? value : 0) + }} + type="number" + value={ + Number.isFinite(autoTopUpThreshold) + ? autoTopUpThreshold + : "" + } + className="h-10 rounded-[8px] border border-white/10 bg-[#080B10] px-3 text-[13px] text-[#FAFAFA] outline-none focus:border-[#0054AD] disabled:opacity-60" + /> +
+
+ + { + const value = Number.parseFloat(event.target.value) + setAutoTopUpAmount(Number.isFinite(value) ? value : 0) + }} + type="number" + value={ + Number.isFinite(autoTopUpAmount) ? autoTopUpAmount : "" + } + className="h-10 rounded-[8px] border border-white/10 bg-[#080B10] px-3 text-[13px] text-[#FAFAFA] outline-none focus:border-[#0054AD] disabled:opacity-60" + /> +
+
+ +
+
+
+ + + + {!isAdmin ? ( +

+ Only owners/admins can purchase credits. +

+ ) : null} +
+ +
+ + {hasPaidPlan ? ( +
+ Credits

- Top-up credits{" "} - - (optional) - + Top-up credits

- {creditRemaining > 0 ? "Add more" : "Add credits"} + {creditRemaining > 0 ? "Add more" : "Buy credits"}

- ) : null} -
+
+ ) : null}
Invoice history diff --git a/apps/web/components/settings/org-context.tsx b/apps/web/components/settings/org-context.tsx index dac97f7e..8c17ed01 100644 --- a/apps/web/components/settings/org-context.tsx +++ b/apps/web/components/settings/org-context.tsx @@ -94,7 +94,7 @@ function PillButton({ children: React.ReactNode onClick: () => void disabled?: boolean - variant?: "default" | "danger" | "primary" + variant?: "default" | "ghost" | "primary" }) { return (
- CANCEL + + CANCEL +
- setConfirmDialog(null)}> + setConfirmDialog(null)} + variant="ghost" + > CANCEL {updateSettings.isPending && ( From fc08bd891fe5d998e29560a3218a623584c94781 Mon Sep 17 00:00:00 2001 From: Vedant Mahajan Date: Fri, 29 May 2026 23:47:19 +0530 Subject: [PATCH 3/4] Add memory drawer (#1015) Co-authored-by: Ishaan Gupta Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.5 --- .../document-modal/graph-list-memories.tsx | 7 +- apps/web/components/document-modal/index.tsx | 279 ++++++++++++------ .../memory-graph/src/components/legend.tsx | 33 ++- .../src/components/memory-graph.tsx | 10 +- 4 files changed, 226 insertions(+), 103 deletions(-) diff --git a/apps/web/components/document-modal/graph-list-memories.tsx b/apps/web/components/document-modal/graph-list-memories.tsx index c8f4ae8d..313424e7 100644 --- a/apps/web/components/document-modal/graph-list-memories.tsx +++ b/apps/web/components/document-modal/graph-list-memories.tsx @@ -169,9 +169,11 @@ function VersionStatus({ export function GraphListMemories({ memoryEntries, documentId, + className, }: { memoryEntries: MemoryEntry[] documentId?: string + className?: string }) { const { effectiveContainerTags } = useProject() const [expandedMemories, setExpandedMemories] = useState>( @@ -193,7 +195,10 @@ export function GraphListMemories({ return (
!open && onClose()}> - + const hasPluginInsights = + pluginDocument && + pluginDocument.kind !== "claude-code-doc" && + pluginDocument.kind !== "openclaw-session" + const hasDocumentInsights = Boolean( + hasPluginInsights || + _document?.summary || + pluginDocument?.summary || + (_document?.memoryEntries && _document.memoryEntries.length > 0), + ) + + const documentPreview = ( +
+ +
+ ) + + const documentInsights = ( +
+ {hasPluginInsights && } + {_document && (_document.summary || pluginDocument?.summary) && ( + + )} + {_document?.memoryEntries && _document.memoryEntries.length > 0 && ( + + )} +
+ ) + + const modalContent = ( + <> + {isMobile ? ( + + {_document?.title} - Document + + ) : ( {_document?.title} - Document -
-
- - </div> - <div className="flex items-center gap-1.5 md:gap-2 shrink-0"> - {pluginDocument?.kind === "claude-code-doc" && - _document?.customId && ( - <CopySessionIdButton sessionId={_document.customId} /> - )} - <DeleteButton - documentId={_document?.id} - customId={_document?.customId} - deleteMutation={deleteMutation} - /> - {_document?.url && ( - <a - href={getDocumentSourceUrl(_document)} - target="_blank" - rel="noopener noreferrer" - 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 ? "size-7 justify-center" : "px-3 py-2", - )} - > - {!isMobile && ( - <span className="line-clamp-1">Visit source</span> - )} - <ArrowUpRightIcon className="size-4 text-[#737373]" /> - </a> + )} + <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} + documentType={_document?.type ?? "text"} + url={_document?.url} + pluginIconSrc={pluginDocument?.pluginIconSrc} + /> + </div> + <div className="flex items-center gap-1.5 md:gap-2 shrink-0"> + {pluginDocument?.kind === "claude-code-doc" && + _document?.customId && ( + <CopySessionIdButton sessionId={_document.customId} /> )} + <DeleteButton + documentId={_document?.id} + customId={_document?.customId} + deleteMutation={deleteMutation} + /> + {_document?.url && ( + <a + href={getDocumentSourceUrl(_document)} + target="_blank" + rel="noopener noreferrer" + 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 ? "size-7 justify-center" : "px-3 py-2", + )} + > + {!isMobile && <span className="line-clamp-1">Visit source</span>} + <ArrowUpRightIcon className="size-4 text-[#737373]" /> + </a> + )} + {isMobile ? ( + <button + className="bg-[#0D121A] size-7 flex items-center justify-center rounded-full transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus:outline-none disabled:pointer-events-none cursor-pointer [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]" + type="button" + tabIndex={-1} + onClick={onClose} + > + <XIcon stroke="#737373" /> + <span className="sr-only">Close</span> + </button> + ) : ( <DialogPrimitive.Close className="bg-[#0D121A] size-7 flex items-center justify-center rounded-full transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus:outline-none disabled:pointer-events-none cursor-pointer [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]" data-slot="dialog-close" @@ -331,50 +387,91 @@ export function DocumentModal({ <XIcon stroke="#737373" /> <span className="sr-only">Close</span> </DialogPrimitive.Close> - </div> + )} </div> - <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( - "bg-[#14161A] rounded-[14px] overflow-hidden flex flex-col shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)] relative", - )} + </div> + {isMobile && hasDocumentInsights ? ( + <Tabs + defaultValue="content" + className="flex min-h-0 flex-1 flex-col pt-1.5" + > + <TabsList className="grid h-11 w-full grid-cols-2 rounded-full border border-[#263142] bg-[#0A1019] p-1 shadow-[inset_0_1px_2px_rgba(255,255,255,0.04),0_1px_3px_rgba(0,0,0,0.35)]"> + <TabsTrigger + value="content" + className="rounded-full text-[15px] font-medium text-[#8E99AA] transition-colors data-[state=active]:bg-[#0B2B60]! data-[state=active]:text-[#F8FAFC] data-[state=active]:shadow-[inset_0_1px_1px_rgba(255,255,255,0.08),0_1px_4px_rgba(54,155,253,0.18)]" + > + Content + </TabsTrigger> + <TabsTrigger + value="insights" + className="rounded-full text-[15px] font-medium text-[#8E99AA] transition-colors data-[state=active]:bg-[#0B2B60]! data-[state=active]:text-[#F8FAFC] data-[state=active]:shadow-[inset_0_1px_1px_rgba(255,255,255,0.08),0_1px_4px_rgba(54,155,253,0.18)]" + > + Insights + </TabsTrigger> + </TabsList> + <TabsContent value="content" className="mt-4 flex min-h-0 flex-1"> + {documentPreview} + </TabsContent> + <TabsContent + value="insights" + className="mt-4 flex min-h-0 flex-1 flex-col overflow-y-auto pb-1 scrollbar-thin" > - <DocumentContent - document={_document} - textEditorProps={textEditorProps} - pluginDocument={pluginDocument} - /> - </div> - <div - id="document-memories-summary" - className={cn( - "gap-3 flex flex-col overflow-hidden", - dmSansClassName(), - )} - > - {pluginDocument && - pluginDocument.kind !== "claude-code-doc" && - pluginDocument.kind !== "openclaw-session" && ( - <PluginDetails parsed={pluginDocument} /> - )} - {_document && (_document.summary || pluginDocument?.summary) && ( - <DocumentSummary - memoryEntries={_document.memoryEntries} - summary={ - (pluginDocument?.summary ?? _document.summary) as string - } - createdAt={_document.createdAt} - /> - )} - {_document?.memoryEntries && _document.memoryEntries.length > 0 && ( - <GraphListMemories - memoryEntries={_document.memoryEntries as MemoryEntry[]} - documentId={_document.id} - /> - )} - </div> + {documentInsights} + </TabsContent> + </Tabs> + ) : isMobile ? ( + <div className="flex min-h-0 flex-1 pt-1.5">{documentPreview}</div> + ) : ( + <div className="flex-1 grid grid-cols-1 md:grid-cols-[2fr_1fr] gap-3 min-h-0 overflow-hidden"> + {documentPreview} + {documentInsights} </div> + )} + </> + ) + + if (isMobile) { + return ( + <Drawer + open={isOpen} + onOpenChange={(open: boolean) => !open && onClose()} + shouldScaleBackground + > + <DrawerContent + className={cn( + "flex flex-col gap-0 border-none bg-[#1B1F24] p-0", + "h-[88svh] max-h-[88svh] overflow-hidden rounded-t-[22px]", + "[&>div:first-child]:bg-[#3A4252] [&>div:first-child]:h-1 [&>div:first-child]:w-9 [&>div:first-child]:mt-2.5 [&>div:first-child]:mb-1", + dmSansClassName(), + )} + style={{ + boxShadow: + "0 -12px 40px rgba(0, 0, 0, 0.45), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset", + }} + > + <div className="flex min-h-0 flex-1 flex-col gap-3 overflow-hidden px-3 pt-2 pb-4"> + {modalContent} + </div> + </DrawerContent> + </Drawer> + ) + } + + return ( + <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}> + <DialogContent + className={cn( + "p-0 border-none bg-[#1B1F24] flex flex-col px-3 md:px-4 pt-3 pb-4 gap-3", + "w-[80%]! max-w-[1158px]! h-[86%]! max-h-[684px]! rounded-[22px]", + dmSansClassName(), + )} + style={{ + boxShadow: + "0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset", + }} + showCloseButton={false} + > + {modalContent} </DialogContent> </Dialog> ) diff --git a/packages/memory-graph/src/components/legend.tsx b/packages/memory-graph/src/components/legend.tsx index 724efdd7..bd6b2f3a 100644 --- a/packages/memory-graph/src/components/legend.tsx +++ b/packages/memory-graph/src/components/legend.tsx @@ -6,6 +6,8 @@ interface LegendProps { edges?: GraphEdge[] isLoading?: boolean colors: GraphThemeColors + compact?: boolean + maxHeight?: number } function HexagonIcon({ @@ -191,6 +193,8 @@ export const Legend = memo(function Legend({ edges = [], isLoading: _isLoading = false, colors, + compact = false, + maxHeight, }: LegendProps) { const [isExpanded, setIsExpanded] = useState(false) const [connectionsExpanded, setConnectionsExpanded] = useState(true) @@ -201,7 +205,8 @@ export const Legend = memo(function Legend({ const outerStyle: React.CSSProperties = { overflow: "hidden", - width: 214, + width: compact ? "min(214px, calc(100vw - 32px))" : 214, + maxWidth: "100%", } const cardStyle: React.CSSProperties = { @@ -209,6 +214,7 @@ export const Legend = memo(function Legend({ backgroundColor: colors.controlBg, border: `1px solid ${colors.controlBorder}`, boxShadow: "0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1)", + maxHeight, } const headerBtnStyle: React.CSSProperties = { @@ -217,6 +223,7 @@ export const Legend = memo(function Legend({ alignItems: "center", gap: 6, width: "100%", + justifyContent: "flex-start", cursor: "pointer", outline: "none", background: "none", @@ -263,6 +270,21 @@ export const Legend = memo(function Legend({ gap: 8, } + const expandedContentStyle: React.CSSProperties = { + marginTop: 16, + display: "flex", + flexDirection: "column", + gap: 16, + ...(compact + ? { + maxHeight: maxHeight ? Math.max(maxHeight - 56, 112) : 220, + overflowY: "auto", + overscrollBehavior: "contain", + paddingRight: 2, + } + : {}), + } + return ( <div style={outerStyle}> <div style={cardStyle}> @@ -281,14 +303,7 @@ export const Legend = memo(function Legend({ </button> {isExpanded && ( - <div - style={{ - marginTop: 16, - display: "flex", - flexDirection: "column", - gap: 16, - }} - > + <div style={expandedContentStyle}> {/* Statistics section */} <div style={{ display: "flex", flexDirection: "column", gap: 8 }}> <span style={sectionLabelStyle}>Statistics</span> diff --git a/packages/memory-graph/src/components/memory-graph.tsx b/packages/memory-graph/src/components/memory-graph.tsx index ed5ec094..dd82f490 100644 --- a/packages/memory-graph/src/components/memory-graph.tsx +++ b/packages/memory-graph/src/components/memory-graph.tsx @@ -83,6 +83,9 @@ export function MemoryGraph({ const graphFitHeight = isCompactViewport ? Math.max(containerSize.height - 170, 240) : containerSize.height + const compactLegendMaxHeight = isCompactViewport + ? Math.max(containerSize.height - 104, 160) + : undefined // Rebuild version chain index during render (not in an effect) so that // the chain data is up-to-date when getChain() is called in useMemo below. @@ -644,13 +647,14 @@ export function MemoryGraph({ const bottomLeftStackStyle: React.CSSProperties = { position: "absolute", - bottom: 16, - left: 16, + bottom: isCompactViewport ? 12 : 16, + left: isCompactViewport ? 12 : 16, zIndex: 20, display: "flex", flexDirection: "column", alignItems: "flex-start", gap: 8, + maxWidth: isCompactViewport ? "calc(100% - 24px)" : undefined, } return ( @@ -722,7 +726,9 @@ export function MemoryGraph({ <Legend colors={colors} edges={edges} + compact={isCompactViewport} isLoading={isLoading} + maxHeight={compactLegendMaxHeight} nodes={nodes} /> </div> From ab068e2ba57867733702cd66086ce52f1563125b Mon Sep 17 00:00:00 2001 From: MaheshtheDev <38828053+MaheshtheDev@users.noreply.github.com> Date: Fri, 29 May 2026 19:54:13 +0000 Subject: [PATCH 4/4] fix(web): restore Nova onboarding analytics events (#1023) The onboarding rework moved the flow to app/(app)/onboarding and dropped the funnel tracking, so onboarding_completed stopped firing on 2026-05-02. - Wire onboarding_step_viewed across idle/processing/done/error transitions - Fire onboarding_completed on real completion (status=done) with source + memories_count - onboarding_profile_submitted now carries { source } - Add onboarding_skipped { from_step } - Remove dead onboardingCompleted() from InitialHeader and unused name/relatable helpers --- apps/web/app/(app)/onboarding/page.tsx | 29 ++++++++++++++-- apps/web/components/initial-header.tsx | 2 -- apps/web/components/integrations-view.tsx | 42 +++++++++++++++++++---- apps/web/lib/analytics.ts | 42 ++++++++++++----------- 4 files changed, 84 insertions(+), 31 deletions(-) diff --git a/apps/web/app/(app)/onboarding/page.tsx b/apps/web/app/(app)/onboarding/page.tsx index 2c5eaf07..3d67b3ba 100644 --- a/apps/web/app/(app)/onboarding/page.tsx +++ b/apps/web/app/(app)/onboarding/page.tsx @@ -41,7 +41,7 @@ import { CheckCircle2, Loader2, } from "lucide-react" -import { analytics } from "@/lib/analytics" +import { analytics, type OnboardingStep } from "@/lib/analytics" import { consumePendingConnectUrl } from "@/lib/constants" type DetectedSource = "x" | "linkedin" | "resume" | null @@ -378,6 +378,13 @@ function isAccountSource(source: DetectedSource): source is "x" | "linkedin" { return source === "x" || source === "linkedin" } +const STATUS_TO_STEP: Record<Status, OnboardingStep> = { + idle: "profile_input", + processing: "processing", + done: "done", + error: "error", +} + function useSpotlightAutoRotation( status: Status, pauseSpotlight: boolean, @@ -555,6 +562,7 @@ export default function OnboardingPage() { const fileRef = useRef<HTMLInputElement>(null) const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null) const skippingRef = useRef(false) + const completedTrackedRef = useRef(false) const [isSkipping, setIsSkipping] = useState(false) const [spotlightCategory, setSpotlightCategory] = useState<SpotlightCategoryId>("productivity") @@ -591,6 +599,21 @@ export default function OnboardingPage() { usePollingCleanup(pollingRef) useDoneAnimation(status, setStampLanded, setVisibleSnippets) + // biome-ignore lint/correctness/useExhaustiveDependencies: fire per status transition only + useEffect(() => { + analytics.onboardingStepViewed({ + step: STATUS_TO_STEP[status], + trigger: "auto", + }) + if (status === "done" && !completedTrackedRef.current) { + completedTrackedRef.current = true + analytics.onboardingCompleted({ + source: isAccountSource(detected) ? detected : undefined, + memories_count: memoriesCount, + }) + } + }, [status]) + const handleChange = (v: string) => { setValue(v) setDetected(detectSource(v)) @@ -619,6 +642,7 @@ export default function OnboardingPage() { if (skippingRef.current) return skippingRef.current = true setIsSkipping(true) + analytics.onboardingSkipped({ from_step: STATUS_TO_STEP[status] }) try { await ensureOrg() const pendingPath = consumePendingConnectUrl() @@ -628,7 +652,7 @@ export default function OnboardingPage() { skippingRef.current = false setIsSkipping(false) } - }, [ensureOrg, router]) + }, [ensureOrg, router, status]) const pollDocument = useCallback((docId: string) => { const maxAttempts = 60 @@ -688,6 +712,7 @@ export default function OnboardingPage() { const handleSubmit = useCallback( async (source: "x" | "linkedin" | "resume", resumeFileOverride?: File) => { + analytics.onboardingProfileSubmitted({ source }) setStatus("processing") setSpotlightCategory("productivity") setPauseSpotlight(false) diff --git a/apps/web/components/initial-header.tsx b/apps/web/components/initial-header.tsx index 831fb77c..95bc9628 100644 --- a/apps/web/components/initial-header.tsx +++ b/apps/web/components/initial-header.tsx @@ -4,7 +4,6 @@ import { Logo } from "@ui/assets/Logo" import { Button } from "@ui/components/button" 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" @@ -23,7 +22,6 @@ export function InitialHeader({ const handleSkip = () => { markOrgOnboarded() - analytics.onboardingCompleted() const pendingPath = consumePendingConnectUrl() router.push(pendingPath ?? "/") } diff --git a/apps/web/components/integrations-view.tsx b/apps/web/components/integrations-view.tsx index e602f070..5a5a7161 100644 --- a/apps/web/components/integrations-view.tsx +++ b/apps/web/components/integrations-view.tsx @@ -1135,7 +1135,10 @@ export function IntegrationsView() { } throw new Error(response.error?.message || "Failed to connect") }, - onMutate: (provider) => setConnectingProvider(provider), + onMutate: (provider) => { + setConnectingProvider(provider) + analytics.connectionAuthStarted({ provider }) + }, onError: (err) => { setConnectingProvider(null) toast.error("Failed to connect", { @@ -1349,6 +1352,13 @@ export function IntegrationsView() { return true }) + const trackCard = (item: Item) => + analytics.integrationCardClicked({ + kind: item.kind, + id: item.id, + name: item.name, + }) + const renderRight = (item: Item): ReactNode => { switch (item.kind) { case "plugin": { @@ -1370,7 +1380,10 @@ export function IntegrationsView() { const busy = connectingPlugin === item.pluginId return ( <PillButton - onClick={() => createPluginKeyMutation.mutate(item.pluginId)} + onClick={() => { + trackCard(item) + createPluginKeyMutation.mutate(item.pluginId) + }} disabled={!!connectingPlugin} > {busy ? ( @@ -1397,7 +1410,10 @@ export function IntegrationsView() { const busy = connectingProvider === item.provider return ( <PillButton - onClick={() => addConnectionMutation.mutate(item.provider)} + onClick={() => { + trackCard(item) + addConnectionMutation.mutate(item.provider) + }} disabled={!!connectingProvider} > {busy ? ( @@ -1415,6 +1431,7 @@ export function IntegrationsView() { return ( <PillButton onClick={() => { + trackCard(item) window.open( (item.action as { type: "external"; href: string }).href, "_blank", @@ -1431,12 +1448,13 @@ export function IntegrationsView() { } return ( <PillButton - onClick={() => + onClick={() => { + trackCard(item) setViewMode( (item.action as { type: "view"; viewMode: ViewParamValue }) .viewMode, ) - } + }} > Connect </PillButton> @@ -1444,13 +1462,23 @@ export function IntegrationsView() { } case "mcp-client": return ( - <PillButton onClick={() => openMcpClient(item.clientKey)}> + <PillButton + onClick={() => { + trackCard(item) + openMcpClient(item.clientKey) + }} + > Connect </PillButton> ) case "import": return ( - <PillButton onClick={() => setViewMode(item.viewMode)}> + <PillButton + onClick={() => { + trackCard(item) + setViewMode(item.viewMode) + }} + > Connect </PillButton> ) diff --git a/apps/web/lib/analytics.ts b/apps/web/lib/analytics.ts index d762654e..132ebeb3 100644 --- a/apps/web/lib/analytics.ts +++ b/apps/web/lib/analytics.ts @@ -1,5 +1,8 @@ import posthog from "posthog-js" +export type OnboardingStep = "profile_input" | "processing" | "done" | "error" +export type OnboardingSource = "x" | "linkedin" | "resume" + // Helper function to safely capture events const safeCapture = ( eventName: string, @@ -40,12 +43,13 @@ export const analytics = { upgradeCompleted: () => safeCapture("upgrade_completed"), billingPortalOpened: () => safeCapture("billing_portal_opened"), - connectionAdded: (provider: string) => - safeCapture("connection_added", { provider }), connectionDeleted: () => safeCapture("connection_deleted"), - connectionAuthStarted: () => safeCapture("connection_auth_started"), - connectionAuthCompleted: () => safeCapture("connection_auth_completed"), - connectionAuthFailed: () => safeCapture("connection_auth_failed"), + connectionAuthStarted: (props: { provider: string }) => + safeCapture("connection_auth_started", props), + + // integrations surface (main Nova page) + integrationCardClicked: (props: { kind: string; id: string; name: string }) => + safeCapture("integration_card_clicked", props), nextAppResearchCtaDismissed: () => safeCapture("next_app_research_cta_dismissed"), @@ -72,21 +76,13 @@ export const analytics = { addDocumentModalOpened: () => safeCapture("add_document_modal_opened"), // onboarding analytics - onboardingStepViewed: (props: { step: string; trigger: "user" | "auto" }) => - safeCapture("onboarding_step_viewed", props), + onboardingStepViewed: (props: { + step: OnboardingStep + trigger: "user" | "auto" + }) => safeCapture("onboarding_step_viewed", props), - onboardingNameSubmitted: (props: { name_length: number }) => - safeCapture("onboarding_name_submitted", props), - - onboardingProfileSubmitted: (props: { - has_twitter: boolean - has_linkedin: boolean - other_links_count: number - description_length: number - }) => safeCapture("onboarding_profile_submitted", props), - - onboardingRelatableSelected: (props: { options: string[] }) => - safeCapture("onboarding_relatable_selected", props), + onboardingProfileSubmitted: (props: { source: OnboardingSource }) => + safeCapture("onboarding_profile_submitted", props), onboardingIntegrationClicked: (props: { integration: string }) => safeCapture("onboarding_integration_clicked", props), @@ -100,7 +96,13 @@ export const analytics = { onboardingXBookmarksDetailOpened: () => safeCapture("onboarding_x_bookmarks_detail_opened"), - onboardingCompleted: () => safeCapture("onboarding_completed"), + onboardingSkipped: (props: { from_step: OnboardingStep }) => + safeCapture("onboarding_skipped", props), + + onboardingCompleted: (props?: { + source?: OnboardingSource + memories_count?: number + }) => safeCapture("onboarding_completed", props), // main app analytics searchOpened: (props: {