diff --git a/e2e/commands.js b/e2e/commands.js index d939c9bc9..ba1ec54c8 100644 --- a/e2e/commands.js +++ b/e2e/commands.js @@ -2,7 +2,7 @@ require("cypress-real-events") require("@4tw/cypress-drag-drop") -const { ctrlOrCmd, escapeRegExp } = require("./utils") +const { ctrlOrCmd, escapeRegExp, seedNotebookOnboarding } = require("./utils") const contextPath = process.env.QDB_HTTP_CONTEXT_WEB_CONSOLE || "" const baseUrl = `http://localhost:9999${contextPath}` @@ -52,6 +52,8 @@ Cypress.on("uncaught:exception", (err) => { }) Cypress.on("window:before:load", (win) => { + seedNotebookOnboarding(win) + const OriginalRO = win.ResizeObserver win.ResizeObserver = class extends OriginalRO { constructor(cb) { @@ -534,7 +536,7 @@ Cypress.Commands.add( Cypress.Commands.add( "handleStorageAndVisit", - (url, clearLocalStorage = true, localStorageItems = {}) => { + (url, clearLocalStorage = true, localStorageItems = {}, onBeforeLoad) => { cy.visit(url, { onBeforeLoad: (win) => { if (clearLocalStorage) { @@ -543,7 +545,9 @@ Cypress.Commands.add( for (const [key, value] of Object.entries(localStorageItems)) { win.localStorage.setItem(key, value) } + seedNotebookOnboarding(win) win.indexedDB.deleteDatabase("web-console") + onBeforeLoad?.(win) }, }) }, diff --git a/e2e/questdb b/e2e/questdb index cdb0ea073..45e00f219 160000 --- a/e2e/questdb +++ b/e2e/questdb @@ -1 +1 @@ -Subproject commit cdb0ea073bc9ea392530c2d6ff3ef132c2fddab7 +Subproject commit 45e00f219b6c9d1ce3fe468204ced3e36f9a408d diff --git a/e2e/tests/console/mcpBridgePermissions.spec.js b/e2e/tests/console/mcpBridgePermissions.spec.js index bfeaa1bcb..41f768ae6 100644 --- a/e2e/tests/console/mcpBridgePermissions.spec.js +++ b/e2e/tests/console/mcpBridgePermissions.spec.js @@ -123,17 +123,15 @@ const deepLinkSuffix = () => // Visit with deep-link params before login so they survive into the SPA boot. const loginAndVisitDeepLink = (seedLocalStorage = {}) => { - cy.visit(`${baseUrl}/${deepLinkSuffix()}`, { - onBeforeLoad: (win) => { - win.localStorage.clear() + cy.handleStorageAndVisit( + `${baseUrl}/${deepLinkSuffix()}`, + true, + seedLocalStorage, + (win) => { win.sessionStorage.clear() - win.indexedDB.deleteDatabase("web-console") - for (const [k, v] of Object.entries(seedLocalStorage)) { - win.localStorage.setItem(k, v) - } installFakeWebSocket(win) }, - }) + ) cy.loginWithUserAndPassword() } diff --git a/e2e/utils/index.js b/e2e/utils/index.js index 337e785d0..633f6954e 100644 --- a/e2e/utils/index.js +++ b/e2e/utils/index.js @@ -4,7 +4,24 @@ const escapeRegExp = (string) => { return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") } +const NOTEBOOK_ONBOARDING_KEY = "notebook.onboarding" + +const NOTEBOOK_ONBOARDING_SUPPRESSED = JSON.stringify({ + mcpEverConnected: true, +}) + +const seedNotebookOnboarding = (win) => { + if (!win.localStorage.getItem(NOTEBOOK_ONBOARDING_KEY)) { + win.localStorage.setItem( + NOTEBOOK_ONBOARDING_KEY, + NOTEBOOK_ONBOARDING_SUPPRESSED, + ) + } +} + module.exports = { ctrlOrCmd, escapeRegExp, + NOTEBOOK_ONBOARDING_KEY, + seedNotebookOnboarding, } diff --git a/public/assets/onboarding/intro-notebooks-hero.png b/public/assets/onboarding/intro-notebooks-hero.png new file mode 100644 index 000000000..6e8be4e62 Binary files /dev/null and b/public/assets/onboarding/intro-notebooks-hero.png differ diff --git a/src/components/CopyButton/index.tsx b/src/components/CopyButton/index.tsx index bcc45076a..35531bf0a 100644 --- a/src/components/CopyButton/index.tsx +++ b/src/components/CopyButton/index.tsx @@ -21,18 +21,24 @@ const StyledCheckboxCircle = styled(CheckboxCircle)` export const CopyButton = ({ text, iconOnly, + icon, size = "md", onCopy, ...props }: { text: string iconOnly?: boolean + icon?: React.ReactNode size?: ButtonProps["size"] onCopy?: () => void } & ButtonProps) => { const [copied, setCopied] = useState(false) const timeoutRef = useRef | null>(null) + const displayIcon = icon ?? ( + + ) + useEffect(() => { return () => { if (timeoutRef.current) { @@ -55,9 +61,7 @@ export const CopyButton = ({ timeoutRef.current = setTimeout(() => setCopied(false), 2000) onCopy?.() }} - {...(!iconOnly && { - prefixIcon: , - })} + {...(!iconOnly && { prefixIcon: displayIcon })} {...props} > {copied && ( @@ -66,7 +70,7 @@ export const CopyButton = ({ size={size === "sm" ? "8px" : "14px"} /> )} - {iconOnly ? : "Copy"} + {iconOnly ? displayIcon : "Copy"} ) } diff --git a/src/components/McpSetupCommand/index.tsx b/src/components/McpSetupCommand/index.tsx new file mode 100644 index 000000000..7e54cfdae --- /dev/null +++ b/src/components/McpSetupCommand/index.tsx @@ -0,0 +1,62 @@ +import React from "react" +import styled from "styled-components" +import { CopyButton } from "../CopyButton" +import { CopyCommand } from "../icons/copy-command" +import { EXPECTED_BRIDGE_VERSION } from "../../utils/mcp/protocolVersion" +import { color } from "../../utils" + +export const SETUP_COMMAND = `npx @questdb/mcp-bridge@${EXPECTED_BRIDGE_VERSION} setup` + +const Code = styled.span` + flex: 1; + min-width: 0; + background: transparent; + font-family: ${({ theme }) => theme.fontMonospace}; + font-size: 1.3rem; + color: ${color("offWhite2")}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +` + +const Package = styled.span` + color: ${color("cyan")}; +` + +const CopyCommandButton = styled(CopyButton)` + && { + height: auto; + min-width: 0; + padding: 0.4rem; + background: transparent; + border: none; + box-shadow: none; + color: ${color("mutedLabel")}; + } + + &&:hover:not([disabled]) { + background: transparent; + color: ${color("foreground")}; + } + + &[data-copied] > svg:first-child { + top: 0.1rem; + right: 0.1rem; + transform: none; + } +` + +export const McpSetupCommand = ({ iconSize = 16 }: { iconSize?: number }) => ( + <> + + npx @questdb/mcp-bridge@{EXPECTED_BRIDGE_VERSION} setup + + } + /> + +) diff --git a/src/components/NotebookOnboardingModal/AgentTranscript.tsx b/src/components/NotebookOnboardingModal/AgentTranscript.tsx new file mode 100644 index 000000000..9ab3199de --- /dev/null +++ b/src/components/NotebookOnboardingModal/AgentTranscript.tsx @@ -0,0 +1,299 @@ +import React, { useEffect, useState } from "react" +import styled, { css, keyframes } from "styled-components" + +const MAX_SPEECH_MS = 2500 +const CALL_STEP_MS = 500 +const CALL_MS = 1000 + +const cursorBlink = keyframes` + 0%, 49% { opacity: 1; } + 50%, 100% { opacity: 0; } +` + +const callBlink = keyframes` + 0%, 50%, 100% { opacity: 1; } + 25%, 75% { opacity: 0.2; } +` + +const Frame = styled.div` + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + overflow: hidden; + padding: 1px; + background: #151515; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 1.2rem; + font-family: ${({ theme }) => theme.fontMonospace}; +` + +const TitleBar = styled.div` + display: flex; + align-items: center; + gap: 1.2rem; + padding: 0.8rem 1.2rem; +` + +const Dots = styled.div` + display: flex; + gap: 0.6rem; +` + +const Dot = styled.span<{ $color: string }>` + width: 0.9rem; + height: 0.9rem; + border-radius: 9999px; + background: ${({ $color }) => $color}; +` + +const RepoName = styled.span` + padding-left: 0.6rem; + font-size: 1.1rem; + color: ${({ theme }) => theme.color.mutedLabel}; +` + +const Body = styled.div` + display: flex; + flex: 1; + min-height: 0; + flex-direction: column; + gap: 0.2rem; + overflow: hidden; + padding: 0.8rem 1.4rem 1.2rem; +` + +const PromptRow = styled.div` + display: flex; + align-items: center; + gap: 1rem; + padding: 0.7rem 1.2rem 0.8rem; + border-radius: 0.3rem; + background: rgba(255, 255, 255, 0.04); + box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, 0.03); +` + +const PromptCaret = styled.span` + font-size: 1.2rem; + color: #93adff; +` + +const PromptText = styled.span` + flex: 1; + min-width: 0; + font-size: 1.1rem; + line-height: 1.6; + color: #93adff; +` + +const Line = styled.div<{ $hidden?: boolean; $blink?: boolean }>` + display: flex; + align-items: flex-start; + gap: 0.7rem; + padding: 0.8rem 1.8rem 0.4rem; + visibility: ${({ $hidden }) => ($hidden ? "hidden" : "visible")}; + ${({ $blink }) => + $blink && + css` + animation: ${callBlink} 1s ease-in-out infinite; + `} +` + +const Diamond = styled.span<{ $muted?: boolean }>` + font-size: 1rem; + line-height: 1.6rem; + color: ${({ $muted, theme }) => + $muted ? theme.color.purple : theme.color.foreground}; +` + +const LineText = styled.span<{ $muted?: boolean }>` + font-size: 1.1rem; + line-height: 1.6; + color: ${({ $muted, theme }) => + $muted ? theme.color.mutedLabel : theme.color.foreground}; +` + +const Cursor = styled.span` + position: relative; + + &::before { + content: ""; + position: absolute; + left: 0.2rem; + top: 0.15rem; + width: 0.7rem; + height: 1.2rem; + background: currentColor; + animation: ${cursorBlink} 0.9s steps(1) infinite; + } +` + +const Ghost = styled.span` + opacity: 0; +` + +const InputBox = styled.div` + display: flex; + align-items: center; + margin-top: 1.2rem; + padding: 0.9rem 1.7rem; + background: #202020; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 0.4rem; +` + +const InputCaret = styled.span` + font-size: 0.9rem; + line-height: 1.6rem; + color: #29c6be; +` + +type TranscriptLine = + | { id: string; kind: "speech"; text: string } + | { id: string; kind: "call"; calls: number } + +const PROMPT = + "Build an FX analytics dashboard in my Web Console for GBPUSD and EURUSD over the last 12 hours, using 5-minute candles. Chart OHLC with volume bars, Bollinger Bands (20, 2σ), RSI (14), and VWAP vs close." + +const LINES: TranscriptLine[] = [ + { + id: "pair", + kind: "speech", + text: "Let me load the QuestDB MCP tools and pair with Web Console.", + }, + { id: "call-pair", kind: "call", calls: 1 }, + { + id: "inspect", + kind: "speech", + text: "Paired. Now let me inspect the tables and materialized views available, and investigate QuestDB documentation.", + }, + { id: "call-inspect", kind: "call", calls: 5 }, + { + id: "create", + kind: "speech", + text: "I have everything I need on the schema and indicators. Now let me create the notebook for you.", + }, + { id: "call-create", kind: "call", calls: 1 }, + { + id: "build", + kind: "speech", + text: "Notebook ready. Now I'll build the entire dashboard — variables, grid layout, markdown explanations, and all 4 charts — in one atomic call.", + }, + { id: "call-build", kind: "call", calls: 2 }, + { + id: "done", + kind: "speech", + text: "The \"FX Dashboard\" is built and live in your Web Console. All charts are laid out in a 2-column grid and auto-refresh. Two notebook variables drive everything — symbol = 'GBPUSD' and range = '$now - 12h..$now' by default — so you can re-point the whole board from the Variables popover.", + }, +] + +// Constant typing speed: the longest line types in MAX_SPEECH_MS, and every +// shorter line takes proportionally less, so a 60-char line isn't dragged out +// to the same duration as a 280-char one. +const MS_PER_CHAR = + MAX_SPEECH_MS / + Math.max( + ...LINES.map((line) => (line.kind === "speech" ? line.text.length : 0)), + ) + +const callText = (calls: number) => + calls <= 1 + ? "Called questdb" + : `Called questdb ${calls} times (ctrl+o to expand)` + +export const AgentTranscript = () => { + const [activeIndex, setActiveIndex] = useState(0) + const [typedChars, setTypedChars] = useState(0) + const [callCount, setCallCount] = useState(1) + + useEffect(() => { + if (activeIndex >= LINES.length) return + const line = LINES[activeIndex] + + if (line.kind === "call") { + setCallCount(1) + let count = 1 + const stepTimer = window.setInterval(() => { + if (count >= line.calls) { + window.clearInterval(stepTimer) + return + } + count += 1 + setCallCount(count) + }, CALL_STEP_MS) + const advanceTimer = window.setTimeout( + () => setActiveIndex((index) => index + 1), + Math.max(CALL_MS, line.calls * CALL_STEP_MS), + ) + return () => { + window.clearInterval(stepTimer) + window.clearTimeout(advanceTimer) + } + } + + const length = line.text.length + setTypedChars(0) + let typed = 0 + const timer = window.setInterval(() => { + typed += 1 + setTypedChars(typed) + if (typed >= length) { + window.clearInterval(timer) + setActiveIndex((index) => index + 1) + } + }, MS_PER_CHAR) + return () => window.clearInterval(timer) + }, [activeIndex]) + + return ( + + + + + + + + questdb/trading-data + + + + + {PROMPT} + + {LINES.map((line, index) => { + const isActive = index === activeIndex + const isPending = index > activeIndex + + let content: React.ReactNode + if (line.kind === "call") { + content = callText(isActive ? callCount : line.calls) + } else if (isActive) { + content = ( + <> + {line.text.slice(0, typedChars)} + + {line.text.slice(typedChars)} + + ) + } else { + content = line.text + } + + return ( + + + {content} + + ) + })} + + + + + + ) +} diff --git a/src/components/NotebookOnboardingModal/Step1.tsx b/src/components/NotebookOnboardingModal/Step1.tsx new file mode 100644 index 000000000..380a4c2e3 --- /dev/null +++ b/src/components/NotebookOnboardingModal/Step1.tsx @@ -0,0 +1,98 @@ +import React from "react" +import styled from "styled-components" +import { ChartBarIcon, GridNineIcon, RobotIcon } from "@phosphor-icons/react" +import { Button } from "../Button" +import { color } from "../../utils" +import { + Badge, + BottomGroup, + Bullet, + BulletList, + ButtonRow, + LeftPane, + PRIMARY_BUTTON_HOOK, + Separator, + Subtitle, + Title, + TitleRow, +} from "./shared" + +const FooterButton = styled(Button)` + flex: 1; + height: auto; + min-height: 3.2rem; + padding: 0.6rem 2rem; + border-radius: 0.4rem; + font-size: 1.4rem; + font-weight: 500; +` + +const CreateButton = styled(FooterButton)` + && { + background: ${color("backgroundDarker")}; + border-color: ${color("black")}; + color: ${color("gray2")}; + } + + &&:hover:not([disabled]) { + background: ${color("selection")}; + border-color: ${color("black")}; + color: ${color("foreground")}; + } +` + +const Bottom = styled(BottomGroup)` + margin-top: auto; +` + +export const Step1 = ({ + onCreateNotebook, + onNext, +}: { + onCreateNotebook: () => void + onNext: () => void +}) => ( + +
+ + Introducing Notebooks + New + + Group your queries, results, and charts in one place. +
+ + + } title="A new way to organise"> + Each notebook opens as its own tab beside your SQL editor: queries, + results, and charts as cells you can revisit anytime and share with + coworkers. + + } + title="Build responsive dashboards" + > + Flip to grid mode and arrange cells into a live, auto-refreshing + dashboard. Drag, drop, resize. + + + + + } + label="Plus, AI agents can build it for you" + /> + + + Create notebook + + + Next + + + +
+) diff --git a/src/components/NotebookOnboardingModal/Step2.tsx b/src/components/NotebookOnboardingModal/Step2.tsx new file mode 100644 index 000000000..94b7c2b58 --- /dev/null +++ b/src/components/NotebookOnboardingModal/Step2.tsx @@ -0,0 +1,114 @@ +import React from "react" +import styled from "styled-components" +import { + AirTrafficControlIcon, + ArrowsLeftRightIcon, + CursorClickIcon, + LockKeyIcon, +} from "@phosphor-icons/react" +import { Button } from "../Button" +import { McpSetupCommand } from "../McpSetupCommand" +import { color } from "../../utils" +import { + Badge, + BottomGroup, + Bullet, + BulletList, + LeftPane, + PRIMARY_BUTTON_HOOK, + Separator, + Subtitle, + Title, + TitleRow, +} from "./shared" + +const CommandGroup = styled.div` + display: flex; + flex-direction: column; + gap: 1.2rem; +` + +const Instruction = styled.p` + margin: 0; + font-size: 1.4rem; + line-height: 1.4; + color: ${color("foreground")}; +` + +const CommandBox = styled.div` + display: flex; + align-items: center; + gap: 1.2rem; + width: 100%; + padding: 0.7rem 0.7rem 0.7rem 1.7rem; + background: #111111; + border: 1px solid rgba(252, 252, 252, 0.2); + border-radius: 0.5rem; + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05); +` + +const CreateButton = styled(Button)` + width: 100%; + height: auto; + min-height: 3.2rem; + padding: 0.7rem 1.4rem; + border-radius: 0.4rem; + font-size: 1.4rem; + font-weight: 500; +` + +export const Step2 = ({ + onCreateNotebook, +}: { + onCreateNotebook: () => void +}) => ( + +
+ + QuestDB MCP Connection + New + + AI agents copilot notebooks to streamline workflows. +
+ + + } title="Two-way handoff"> + Build and rearrange cells and notes, tweak and run queries. Hand + analysis back and forth with your agent. + + } title="Privacy first"> + The MCP bridge runs locally on your machine, your data is never sent to + a remote server. + + } title="Full control"> + Agents can access only what you permit: granular control of schema + access, read, and write permissions. + + + + + + + Install the MCP using your terminal and use it in any coding agent + seamlessly + + + + + + + } + label="Or, create manually and connect later" + /> + + + Create new notebook + + +
+) diff --git a/src/components/NotebookOnboardingModal/index.tsx b/src/components/NotebookOnboardingModal/index.tsx new file mode 100644 index 000000000..a424d4f7f --- /dev/null +++ b/src/components/NotebookOnboardingModal/index.tsx @@ -0,0 +1,205 @@ +import React, { useEffect, useRef, useState } from "react" +import * as RadixDialog from "@radix-ui/react-dialog" +import styled, { css } from "styled-components" +import { XIcon } from "@phosphor-icons/react" +import { Overlay } from "../Overlay" +import { ForwardRef } from "../ForwardRef" +import { color } from "../../utils" +import { useLocalStorage } from "../../providers/LocalStorageProvider" +import { useEditor } from "../../providers/EditorProvider" +import { createDefaultNotebookViewState } from "../../store/notebook" +import { shouldShowNotebookModal } from "../../utils/notebookOnboarding" +import { PRIMARY_BUTTON_HOOK } from "./shared" +import { Step1 } from "./Step1" +import { Step2 } from "./Step2" +import { AgentTranscript } from "./AgentTranscript" + +const SURFACE = "#050505" + +const dialogShow = css` + @keyframes onboardingShow { + from { + opacity: 0; + } + to { + opacity: 1; + } + } +` + +const Content = styled(RadixDialog.Content)<{ $tall?: boolean }>` + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 101; + display: flex; + width: 90vw; + max-width: 96rem; + min-height: ${({ $tall }) => + $tall ? "min(66.6rem, 85vh)" : "min(41.6rem, 85vh)"}; + max-height: 85vh; + overflow: hidden; + background: ${SURFACE}; + border: 1px solid rgba(252, 252, 252, 0.15); + border-radius: 0.75rem; + box-shadow: 0 0.7rem 3rem -1rem ${({ theme }) => theme.color.black}; + + ${dialogShow} + + &[data-state="open"] { + animation: onboardingShow 0.25s cubic-bezier(0.16, 1, 0.3, 1); + } + + &:focus { + outline: none; + } +` + +const RightColumn = styled.div` + position: relative; + flex-shrink: 0; + align-self: stretch; + width: 52rem; + background: ${SURFACE}; + border-left: 1px solid rgba(222, 222, 222, 0.04); + + @media (max-width: 860px) { + display: none; + } +` + +const HeroImage = styled.img` + display: block; + width: 100%; + height: 100%; + object-fit: cover; +` + +const TerminalWrap = styled.div` + display: flex; + height: 100%; + padding: 2rem; +` + +const GradientOverlay = styled.div` + position: absolute; + top: 2rem; + bottom: 2rem; + left: 55%; + right: 0; + background: linear-gradient(to right, rgba(5, 5, 5, 0), ${SURFACE}); + pointer-events: none; +` + +const CloseButton = styled.button` + position: absolute; + top: 0.9rem; + right: 0.9rem; + z-index: 1; + display: flex; + padding: 0.8rem; + background: #363636; + border: none; + border-radius: 0.25rem; + color: ${color("offWhite2")}; + cursor: pointer; + + &:hover { + background: #4a4a4a; + color: ${color("foreground")}; + } +` + +const HiddenTitle = styled(RadixDialog.Title)` + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +` + +export const NotebookOnboardingModal = () => { + const { notebookOnboarding, updateNotebookOnboarding } = useLocalStorage() + const { addBuffer } = useEditor() + const [open, setOpen] = useState(() => + shouldShowNotebookModal(notebookOnboarding), + ) + const [step, setStep] = useState<0 | 1>(0) + const contentRef = useRef(null) + + useEffect(() => { + if (open) { + updateNotebookOnboarding({ showNotebookPromo: false }) + } + }, [open, updateNotebookOnboarding]) + + useEffect(() => { + if (open) { + contentRef.current + ?.querySelector(`[data-hook="${PRIMARY_BUTTON_HOOK}"]`) + ?.focus() + } + }, [open, step]) + + const createNotebook = () => { + updateNotebookOnboarding({ showMcpPromo: false }) + void addBuffer({ notebookViewState: createDefaultNotebookViewState() }) + setOpen(false) + } + + return ( + { + if (!isOpen) setOpen(false) + }} + > + + + + + event.preventDefault()} + > + + {step === 0 ? "Introducing Notebooks" : "QuestDB MCP Connection"} + + setOpen(false)}> + + + {step === 0 ? ( + setStep(1)} + /> + ) : ( + + )} + + {step === 0 ? ( + + ) : ( + <> + + + + + + )} + + + + + ) +} diff --git a/src/components/NotebookOnboardingModal/shared.tsx b/src/components/NotebookOnboardingModal/shared.tsx new file mode 100644 index 000000000..0b707f28b --- /dev/null +++ b/src/components/NotebookOnboardingModal/shared.tsx @@ -0,0 +1,169 @@ +import React, { ReactNode } from "react" +import styled from "styled-components" +import { color } from "../../utils" + +export const PRIMARY_BUTTON_HOOK = "onboarding-primary" + +export const LeftPane = styled.div<{ $center?: boolean }>` + display: flex; + flex: 1; + min-width: 0; + min-height: 0; + flex-direction: column; + gap: 2rem; + padding: 3.2rem 3.2rem 2.8rem; + overflow-y: auto; + ${({ $center }) => $center && "justify-content: safe center;"} +` + +export const TitleRow = styled.div` + display: flex; + align-items: center; + gap: 0.8rem; +` + +export const Title = styled.h2` + margin: 0; + font-size: 2.25rem; + font-weight: 600; + line-height: 1.4; + color: ${color("foreground")}; +` + +export const Badge = styled.span` + display: inline-flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + height: 2.4rem; + padding: 0 1.1rem; + border: 1px solid ${color("pinkBadge")}; + border-bottom-width: 2px; + border-radius: 0.25rem; + font-size: 1.3rem; + font-weight: 500; + line-height: 1; + color: ${color("pinkBadge")}; +` + +export const Subtitle = styled.p` + margin: 0.7rem 0 0; + font-size: 1.4rem; + line-height: 1.5; + color: ${color("gray2")}; +` + +const List = styled.div` + display: flex; + flex-direction: column; + gap: 1.4rem; + padding-top: 1rem; +` + +const Item = styled.div` + display: flex; + align-items: flex-start; + gap: 1.2rem; +` + +const ItemIcon = styled.div` + display: flex; + padding-top: 0.2rem; + color: ${color("pink")}; +` + +const ItemBody = styled.div` + display: flex; + flex: 1; + min-width: 0; + flex-direction: column; + gap: 0.2rem; +` + +const ItemTitle = styled.p` + margin: 0; + font-size: 1.4rem; + font-weight: 500; + color: ${color("foreground")}; +` + +const ItemText = styled.p` + margin: 0; + font-size: 1.28rem; + line-height: 1.5; + color: ${color("mutedLabel")}; +` + +export const BulletList = ({ children }: { children: ReactNode }) => ( + {children} +) + +export const Bullet = ({ + icon, + title, + children, +}: { + icon: ReactNode + title: string + children: ReactNode +}) => ( + + {icon} + + {title} + {children} + + +) + +const SeparatorRow = styled.div` + display: flex; + align-items: center; + gap: 1rem; + padding: 0.4rem; +` + +const SeparatorLine = styled.div` + flex: 1; + height: 1px; + background: ${color("selection")}; +` + +const SeparatorLabel = styled.div` + display: flex; + align-items: center; + gap: 0.8rem; + color: ${color("mutedLabel")}; + font-size: 1.3rem; + white-space: nowrap; +` + +export const Separator = ({ + icon, + label, +}: { + icon: ReactNode + label: string +}) => ( + + + + {icon} + {label} + + + +) + +export const BottomGroup = styled.div` + display: flex; + flex-direction: column; + gap: 1.6rem; +` + +export const ButtonRow = styled.div` + display: flex; + align-items: center; + gap: 1.6rem; + width: 100%; +` diff --git a/src/components/icons/copy-command.tsx b/src/components/icons/copy-command.tsx new file mode 100644 index 000000000..370d0707b --- /dev/null +++ b/src/components/icons/copy-command.tsx @@ -0,0 +1,29 @@ +import React from "react" + +export const CopyCommand = ({ + size = 16, + color = "currentColor", +}: { + size?: number | string + color?: string +}) => ( + + + + +) diff --git a/src/providers/LocalStorageProvider/index.tsx b/src/providers/LocalStorageProvider/index.tsx index 2fe245ca7..28d35622a 100644 --- a/src/providers/LocalStorageProvider/index.tsx +++ b/src/providers/LocalStorageProvider/index.tsx @@ -28,6 +28,7 @@ import React, { useContext, useCallback, useEffect, + useRef, } from "react" import { getValue, setValue } from "../../utils/localStorage" import { StoreKey } from "../../utils/localStorage/types" @@ -38,6 +39,7 @@ import { SettingsType, LeftPanelState, LeftPanelType, + NotebookOnboarding, } from "./types" import { reconcileSettings } from "../../utils/ai/settings" @@ -45,6 +47,13 @@ export const DEFAULT_AI_ASSISTANT_SETTINGS: AiAssistantSettings = { providers: {}, } +export const DEFAULT_NOTEBOOK_ONBOARDING: NotebookOnboarding = { + mcpEverConnected: false, + showNotebookPromo: true, + showMcpPromo: true, + collapseMcpPromo: false, +} + const defaultConfig: LocalConfig = { editorCol: 10, editorLine: 10, @@ -59,6 +68,7 @@ const defaultConfig: LocalConfig = { width: 350, }, aiChatPanelWidth: 500, + notebookOnboarding: DEFAULT_NOTEBOOK_ONBOARDING, } type ContextProps = { @@ -75,6 +85,8 @@ type ContextProps = { aiAssistantSettings: AiAssistantSettings aiChatPanelWidth: number updateAiChatPanelWidth: (width: number) => void + notebookOnboarding: NotebookOnboarding + updateNotebookOnboarding: (patch: Partial) => void } const defaultValues: ContextProps = { @@ -91,6 +103,8 @@ const defaultValues: ContextProps = { aiAssistantSettings: defaultConfig.aiAssistantSettings, aiChatPanelWidth: defaultConfig.aiChatPanelWidth, updateAiChatPanelWidth: (_width: number) => undefined, + notebookOnboarding: defaultConfig.notebookOnboarding, + updateNotebookOnboarding: (_patch: Partial) => undefined, } export const LocalStorageContext = createContext(defaultValues) @@ -216,6 +230,35 @@ export const LocalStorageProvider = ({ setAiChatPanelWidth(width) }, []) + const getNotebookOnboarding = (): NotebookOnboarding => { + const stored = getValue(StoreKey.NOTEBOOK_ONBOARDING) + if (stored) { + try { + return { + ...defaultConfig.notebookOnboarding, + ...(JSON.parse(stored) as Partial), + } + } catch (e) { + return defaultConfig.notebookOnboarding + } + } + return defaultConfig.notebookOnboarding + } + + const [notebookOnboarding, setNotebookOnboarding] = + useState(getNotebookOnboarding) + const notebookOnboardingRef = useRef(notebookOnboarding) + + const updateNotebookOnboarding = useCallback( + (patch: Partial) => { + const next = { ...notebookOnboardingRef.current, ...patch } + notebookOnboardingRef.current = next + setValue(StoreKey.NOTEBOOK_ONBOARDING, JSON.stringify(next)) + setNotebookOnboarding(next) + }, + [], + ) + const updateSettings = (key: StoreKey, value: SettingsType) => { if (key === StoreKey.AI_ASSISTANT_SETTINGS) { setValue(key, JSON.stringify(value)) @@ -281,6 +324,8 @@ export const LocalStorageProvider = ({ aiAssistantSettings, aiChatPanelWidth, updateAiChatPanelWidth, + notebookOnboarding, + updateNotebookOnboarding, }} > {children} diff --git a/src/providers/LocalStorageProvider/types.ts b/src/providers/LocalStorageProvider/types.ts index 3c7ca8b1e..0ad9f3bb8 100644 --- a/src/providers/LocalStorageProvider/types.ts +++ b/src/providers/LocalStorageProvider/types.ts @@ -37,6 +37,13 @@ export type LeftPanelState = { width: number } +export type NotebookOnboarding = { + mcpEverConnected: boolean + showNotebookPromo: boolean + showMcpPromo: boolean + collapseMcpPromo: boolean +} + export type LocalConfig = { editorCol: number editorLine: number @@ -48,4 +55,5 @@ export type LocalConfig = { leftPanelState: LeftPanelState aiAssistantSettings: AiAssistantSettings aiChatPanelWidth: number + notebookOnboarding: NotebookOnboarding } diff --git a/src/providers/MCPBridgeProvider/PairingConsentModal.tsx b/src/providers/MCPBridgeProvider/PairingConsentModal.tsx index 828c6920d..653ce3d79 100644 --- a/src/providers/MCPBridgeProvider/PairingConsentModal.tsx +++ b/src/providers/MCPBridgeProvider/PairingConsentModal.tsx @@ -100,7 +100,6 @@ const Fields = styled.dl` font-size: 1.1rem; color: ${({ theme }) => theme.color.gray2}; text-transform: uppercase; - letter-spacing: 0.08em; font-weight: 600; margin: 0; } diff --git a/src/providers/MCPBridgeProvider/index.tsx b/src/providers/MCPBridgeProvider/index.tsx index 87ff959e6..5c50719ca 100644 --- a/src/providers/MCPBridgeProvider/index.tsx +++ b/src/providers/MCPBridgeProvider/index.tsx @@ -36,6 +36,7 @@ import { } from "../../utils/tools/permissions" import { consumePendingPairFromUrl } from "../../utils/mcp/consumePendingPair" import { QuestContext } from "../QuestProvider" +import { useLocalStorage } from "../LocalStorageProvider" import { on as onUserAction, onUserEdit } from "../../utils/notebookAIBridge" import type { ToolDefinition } from "../../utils/ai/types" import { @@ -96,6 +97,7 @@ export const MCPBridgeProvider: React.FC<{ children: React.ReactNode }> = ({ children, }) => { const { quest } = useContext(QuestContext) + const { notebookOnboarding, updateNotebookOnboarding } = useLocalStorage() const [url, setUrl] = useState(null) const [token, setToken] = useState(null) const [status, setStatus] = useState("disconnected") @@ -414,6 +416,14 @@ export const MCPBridgeProvider: React.FC<{ children: React.ReactNode }> = ({ if (stored?.consented) clearPendingPair() }, [status, url, token, lastError]) + // Remember the first successful bridge connection to suppress the + // notebooks onboarding surfaces from then on. + useEffect(() => { + if (status === "connected" && !notebookOnboarding.mcpEverConnected) { + updateNotebookOnboarding({ mcpEverConnected: true }) + } + }, [status, notebookOnboarding.mcpEverConnected, updateNotebookOnboarding]) + // Don't flip consentSucceeded inside the timer: React 17 doesn't batch // setStates in setTimeout, and Radix keeps the modal mounted during its // 0.25s exit animation, so a flip would briefly swap success → default. diff --git a/src/scenes/Editor/AIChatWindow/DateSeparator.tsx b/src/scenes/Editor/AIChatWindow/DateSeparator.tsx index 2ca948c57..fc5b7b7d1 100644 --- a/src/scenes/Editor/AIChatWindow/DateSeparator.tsx +++ b/src/scenes/Editor/AIChatWindow/DateSeparator.tsx @@ -26,7 +26,6 @@ const LabelContainer = styled.div` const Label = styled.span` font-size: 1.3rem; - letter-spacing: 0.016rem; white-space: nowrap; ` diff --git a/src/scenes/Editor/Notebook/NotebookMcpPromo/index.tsx b/src/scenes/Editor/Notebook/NotebookMcpPromo/index.tsx new file mode 100644 index 000000000..748deea08 --- /dev/null +++ b/src/scenes/Editor/Notebook/NotebookMcpPromo/index.tsx @@ -0,0 +1,174 @@ +import React from "react" +import styled from "styled-components" +import { CaretDownIcon, CaretRightIcon, XIcon } from "@phosphor-icons/react" +import { McpSetupCommand } from "../../../../components/McpSetupCommand" +import { + Badge, + TitleRow, +} from "../../../../components/NotebookOnboardingModal/shared" +import { useLocalStorage } from "../../../../providers/LocalStorageProvider" +import { + isMcpPromoCollapsed, + shouldShowMcpPromo, +} from "../../../../utils/notebookOnboarding" +import { color } from "../../../../utils" + +const Container = styled.div` + display: flex; + align-items: flex-start; + gap: 0.8rem; + padding: 1.2rem; + border: 1.5px dashed ${color("pinkPrimary")}; + border-radius: 0.4rem; +` + +const IconButton = styled.button` + display: flex; + align-items: center; + justify-content: center; + padding: 0.4rem; + background: transparent; + border: none; + border-radius: 0.4rem; + color: ${color("offWhite")}; + cursor: pointer; + + &:hover { + background: ${color("selection")}; + color: ${color("foreground")}; + } +` + +const CaretButton = styled(IconButton)` + color: ${color("pinkBadge")}; + + &:hover { + background: ${color("selection")}; + color: ${color("pinkBadge")}; + } +` + +const Content = styled.div` + display: flex; + flex: 1; + min-width: 0; + flex-direction: column; + gap: 2rem; +` + +const Title = styled.h2` + margin: 0; + font-size: 2rem; + font-weight: 600; + line-height: 1.4; + color: ${color("foreground")}; +` + +const Header = styled.div` + display: flex; + flex-direction: column; + gap: 0.8rem; +` + +const Description = styled.p` + margin: 0; + font-size: 1.6rem; + line-height: 1.5; + color: ${color("gray2")}; +` + +const Actions = styled.div` + display: flex; + flex-direction: column; + gap: 0.8rem; +` + +const CommandBox = styled.div` + display: inline-flex; + align-items: center; + gap: 1.2rem; + width: fit-content; + max-width: 100%; + padding: 0.5rem 0.6rem 0.5rem 1.7rem; + background: #1a1b23; + border: 1px solid rgba(252, 252, 252, 0.15); + border-radius: 0.4rem; + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05); +` + +const FootNote = styled.p` + margin: 0; + font-size: 1.28rem; + line-height: 1.5; + color: ${color("mutedLabel")}; +` + +export const NotebookMcpPromo = () => { + const { notebookOnboarding, updateNotebookOnboarding } = useLocalStorage() + + const isVisible = shouldShowMcpPromo(notebookOnboarding) + const isCollapsed = isMcpPromoCollapsed(notebookOnboarding) + + if (!isVisible) { + return null + } + + const title = ( + + Work with your coding agent via MCP + New + + ) + + const close = () => updateNotebookOnboarding({ showMcpPromo: false }) + + if (isCollapsed) { + return ( + + updateNotebookOnboarding({ collapseMcpPromo: false })} + > + + + {title} + + + + + ) + } + + return ( + + updateNotebookOnboarding({ collapseMcpPromo: true })} + > + + + +
+ {title} + + A shared notebook your AI agent can drive over MCP. You keep control + of the data. Hand analysis back and forth with your agent – queries, + charts, and dashboards in one surface. + +
+ + + + + + Install the MCP using your terminal and use it in any coding agent + seamlessly + + +
+ + + +
+ ) +} diff --git a/src/scenes/Editor/Notebook/NotebookToolbar.tsx b/src/scenes/Editor/Notebook/NotebookToolbar.tsx index 979410907..fe24f1b66 100644 --- a/src/scenes/Editor/Notebook/NotebookToolbar.tsx +++ b/src/scenes/Editor/Notebook/NotebookToolbar.tsx @@ -53,7 +53,6 @@ const Name = styled.span` min-width: 0; font-size: 1.6rem; font-weight: 600; - letter-spacing: -0.32px; color: ${color("foreground")}; white-space: nowrap; overflow: hidden; diff --git a/src/scenes/Editor/Notebook/index.tsx b/src/scenes/Editor/Notebook/index.tsx index 94cb83ddc..1e7bae42c 100644 --- a/src/scenes/Editor/Notebook/index.tsx +++ b/src/scenes/Editor/Notebook/index.tsx @@ -28,6 +28,7 @@ import { MarkdownCell } from "./cells/MarkdownCell" import type { NotebookCell } from "../../../store/notebook" import { AddCellBottom, AddCellBetween } from "./cells/AddCellButton" import { NotebookToolbar } from "./NotebookToolbar" +import { NotebookMcpPromo } from "./NotebookMcpPromo" import { renderEdgeHandle } from "./resize" import { CELL_CHROME_PX, @@ -79,6 +80,12 @@ const CellListContainer = styled.div<{ $maximized?: boolean }>` overflow-anchor: none; ` +const PromoGridSlot = styled.div` + &:not(:empty) { + margin-bottom: 2rem; + } +` + const CellItem = styled.div<{ $maximized?: boolean }>` display: flex; flex-direction: column; @@ -313,6 +320,7 @@ const ListLayout: React.FC = () => { if (!target.closest("[data-notebook-cell]")) setFocusedCell(null) }} > + {cells.map((cell, index) => ( @@ -579,6 +587,9 @@ const GridLayout: React.FC = () => { } }} > + + + {/* No `key` on ResponsiveGridLayout: keying it on cellIds would * unmount every Cell/DrawCanvas on add/remove and wipe chart state. * rgl reconciles children by their own key matched against @@ -751,6 +762,7 @@ const NotebookContent: React.FC = () => { + diff --git a/src/scenes/Footer/MCPBridgeStatus/PairPopover.tsx b/src/scenes/Footer/MCPBridgeStatus/PairPopover.tsx index 1f7c64d00..5f7fde875 100644 --- a/src/scenes/Footer/MCPBridgeStatus/PairPopover.tsx +++ b/src/scenes/Footer/MCPBridgeStatus/PairPopover.tsx @@ -116,7 +116,6 @@ const FieldLabel = styled.span` font-size: 1.1rem; color: ${({ theme }) => theme.color.gray2}; text-transform: uppercase; - letter-spacing: 0.08em; font-weight: 600; ` diff --git a/src/scenes/Footer/MCPBridgeStatus/PermissionsSection.tsx b/src/scenes/Footer/MCPBridgeStatus/PermissionsSection.tsx index 74dd85e46..4856cfd83 100644 --- a/src/scenes/Footer/MCPBridgeStatus/PermissionsSection.tsx +++ b/src/scenes/Footer/MCPBridgeStatus/PermissionsSection.tsx @@ -61,7 +61,6 @@ const Field = styled.div` const FieldLabel = styled.span` color: ${({ theme }) => theme.color.gray2}; text-transform: uppercase; - letter-spacing: 0.08em; font-weight: 600; ` diff --git a/src/scenes/Layout/index.tsx b/src/scenes/Layout/index.tsx index fd8382691..e99079399 100644 --- a/src/scenes/Layout/index.tsx +++ b/src/scenes/Layout/index.tsx @@ -48,6 +48,7 @@ import { eventBus } from "../../modules/EventBus" import { EventType } from "../../modules/EventBus/types" import { AIStatusProvider } from "../../providers/AIStatusProvider" import { MCPBridgeProvider } from "../../providers/MCPBridgeProvider" +import { NotebookOnboardingModal } from "../../components/NotebookOnboardingModal" const Page = styled.div` display: flex; @@ -125,6 +126,7 @@ const Layout = () => {