diff --git a/src/components/AgentChatWidget.tsx b/src/components/AgentChatWidget.tsx index 845f4b1..99cc86c 100644 --- a/src/components/AgentChatWidget.tsx +++ b/src/components/AgentChatWidget.tsx @@ -1,36 +1,182 @@ import { useEffect } from "react"; -// The Intercom-style support agent, fixed bottom-right (mounted by __root). -// The chrome (dither launcher, teaser, panel) and the postMessage bridge ship -// as the hosted widget script served by the embedded-agent worker — the same -// embed superwall.com uses — so the widget deploys with the agent and can -// never drift from its bridge protocol. This component used to own a ported -// copy of all that chrome; now it just injects the script on the client. -// -// The script auto-boots the floating presentation: follows the docs theme -// live (.dark/.light on ), re-pushes page context on SPA navigations -// (with each page's same-origin Markdown twin, e.g. /docs/ios → /docs/ios.md), -// persists open/last-chat state in the same agentWidget.* localStorage keys -// as before, and listens for OPEN_AGENT_EVENT to open with a seeded query — -// which the agent pre-fills and auto-sends. data-z-index pins the original -// z-40 stacking. All use cases and config: +// The Intercom-style support agent is mounted by the hosted embedded-agent +// widget. Keep the docs site on that one-script integration so the chrome, +// bridge protocol, floating mode, and docked mode ship with the agent. // https://github.com/superwall/embedded-agent#embedding-the-agent const WIDGET_SRC = "https://embedded-agent.staffbar.workers.dev/widget.js"; const SCRIPT_ID = "superwall-agent-widget-script"; +const WIDGET_Z_INDEX = 40; /** Fired by the search dialog's "Ask AI" button to open the widget (detail: {query}). */ export const OPEN_AGENT_EVENT = "superwall:open-agent"; -export function AgentChatWidget() { - useEffect(() => { - if (document.getElementById(SCRIPT_ID)) return; +type AgentPresentation = "floating" | "docked"; + +interface AgentWidgetConfig { + presentation?: AgentPresentation; + embed?: "home" | "dashboard"; + theme?: "auto" | "light" | "dark"; + zIndex?: number; + pageMarkdown?: boolean; +} + +interface AgentWidgetController { + init(config?: AgentWidgetConfig): unknown; + open(options?: { query?: string }): void; + close(): void; + isOpen(): boolean; + destroy(): void; +} + +declare global { + interface Window { + SuperwallAgent?: AgentWidgetController; + } +} + +let widgetScriptPromise: Promise | null = null; +let activePresentation: AgentPresentation | null = null; + +function loadWidgetScript() { + if (typeof window === "undefined") return Promise.resolve(); + if (window.SuperwallAgent) return Promise.resolve(); + if (widgetScriptPromise) return widgetScriptPromise; + + widgetScriptPromise = new Promise((resolve, reject) => { + const existing = document.getElementById(SCRIPT_ID) as HTMLScriptElement | null; + + if (existing?.dataset.loaded === "true") { + resolve(); + return; + } + + existing?.remove(); const script = document.createElement("script"); + + const finish = () => { + script.dataset.loaded = "true"; + resolve(); + }; + const fail = () => { + script.remove(); + widgetScriptPromise = null; + reject(new Error("Failed to load the Superwall agent widget.")); + }; + script.addEventListener("load", finish, { once: true }); + script.addEventListener("error", fail, { once: true }); + script.id = SCRIPT_ID; script.src = WIDGET_SRC; - script.dataset.zIndex = "40"; + script.defer = true; + script.dataset.manual = ""; document.body.appendChild(script); - // No teardown: the root layout mounts this once for the app's lifetime. + }); + + return widgetScriptPromise; +} + +async function ensureAgentWidget(presentation: AgentPresentation) { + await loadWidgetScript(); + const controller = window.SuperwallAgent; + if (!controller) return null; + + const widgetHostExists = Boolean(document.getElementById("superwall-agent-widget")); + if (activePresentation !== presentation || !widgetHostExists) { + if (activePresentation || widgetHostExists) { + controller.destroy(); + } + controller.init({ + presentation, + embed: "home", + theme: "auto", + zIndex: WIDGET_Z_INDEX, + pageMarkdown: true, + }); + activePresentation = presentation; + } + + return controller; +} + +export async function openAgentWidget(options: { + presentation?: AgentPresentation; + query?: string; +} = {}) { + try { + const controller = await ensureAgentWidget(options.presentation ?? "floating"); + controller?.open({ query: options.query?.trim() || undefined }); + } catch { + // The hosted agent is progressive enhancement; leave the docs page usable. + } +} + +async function toggleAgentWidget(presentation: AgentPresentation) { + try { + const controller = await ensureAgentWidget(presentation); + if (!controller) return; + + if (controller.isOpen()) { + controller.close(); + return; + } + + controller.open(); + } catch { + // The hosted agent is progressive enhancement; leave the docs page usable. + } +} + +function closeAgentWidget() { + if (window.SuperwallAgent?.isOpen()) { + window.SuperwallAgent.close(); + return true; + } + return false; +} + +export function AgentChatWidget() { + useEffect(() => { + void ensureAgentWidget("floating").catch(() => { + // The hosted agent is progressive enhancement; leave the docs page usable. + }); + + const onOpenAgent = (event: Event) => { + event.stopImmediatePropagation(); + const { query } = (event as CustomEvent<{ query?: string }>).detail ?? {}; + void openAgentWidget({ query, presentation: "floating" }); + }; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.isComposing) return; + + if (event.key === "Escape" && closeAgentWidget()) { + event.preventDefault(); + event.stopPropagation(); + return; + } + + if (event.key.toLowerCase() !== "i" || !(event.metaKey || event.ctrlKey)) return; + if (event.shiftKey && !event.metaKey) return; + + event.preventDefault(); + if (event.shiftKey) { + void toggleAgentWidget("docked"); + return; + } + + if (closeAgentWidget()) return; + void openAgentWidget({ presentation: "floating" }); + }; + + window.addEventListener(OPEN_AGENT_EVENT, onOpenAgent); + window.addEventListener("keydown", onKeyDown); + + return () => { + window.removeEventListener(OPEN_AGENT_EVENT, onOpenAgent); + window.removeEventListener("keydown", onKeyDown); + }; }, []); return null; diff --git a/src/components/SearchDialog.tsx b/src/components/SearchDialog.tsx index 2196184..6488fca 100644 --- a/src/components/SearchDialog.tsx +++ b/src/components/SearchDialog.tsx @@ -24,7 +24,7 @@ import { type SearchScope, } from "@/lib/search.shared"; import { createStaticSearchClient, preloadStaticSearch } from "@/lib/static-search-client"; -import { OPEN_AGENT_EVENT } from "@/components/AgentChatWidget"; +import { openAgentWidget } from "@/components/AgentChatWidget"; import { cn } from "@/lib/cn"; import { buildDocsApiPath } from "@/lib/url-base"; import { PREFILL_DOCS_SEARCH_EVENT, type PrefillDocsSearchDetail } from "@/lib/docs-search-events"; @@ -77,16 +77,16 @@ export function CustomSearchDialog(props: SharedProps) { !query.error && Array.isArray(query.data) && query.data.length > 0; - // Open the Superwall agent widget (bottom-right), seeding it with the typed - // query, and dismiss the search dialog. The agent reads the query from the - // iframe URL (cold) or a `prompt` bridge message (warm) — see the embedded - // agent's EMBED_QUERY_PREFILL_TASK. - const openAgent = useEffectEvent((message = search) => { - window.dispatchEvent( - new CustomEvent(OPEN_AGENT_EVENT, { detail: { query: message.trim() } }), - ); - props.onOpenChange(false); - }); + // Open the Superwall agent widget, seeding it with the typed query, and + // dismiss the search dialog. The agent reads the query from the iframe URL + // (cold) or a `prompt` bridge message (warm) — see the embedded agent's + // EMBED_QUERY_PREFILL_TASK. + const openAgent = useEffectEvent( + (message = search, presentation: "floating" | "docked" = "floating") => { + void openAgentWidget({ query: message.trim(), presentation }); + props.onOpenChange(false); + }, + ); useEffect(() => { const handle = window.setTimeout(() => { @@ -141,19 +141,21 @@ export function CustomSearchDialog(props: SharedProps) { }, []); useEffect(() => { + if (!props.open) return; + const onKeyDown = (event: KeyboardEvent) => { if (event.isComposing) return; - if (!(event.metaKey || event.ctrlKey)) return; - const key = event.key.toLowerCase(); - if (key !== "i") return; + if (event.key.toLowerCase() !== "i" || !(event.metaKey || event.ctrlKey)) return; + if (event.shiftKey && !event.metaKey) return; event.preventDefault(); - openAgent(); + event.stopImmediatePropagation(); + openAgent(search, event.shiftKey ? "docked" : "floating"); }; - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, [openAgent]); + window.addEventListener("keydown", onKeyDown, true); + return () => window.removeEventListener("keydown", onKeyDown, true); + }, [openAgent, props.open, search]); return ( @@ -162,31 +164,33 @@ export function CustomSearchDialog(props: SharedProps) { - diff --git a/src/styles/app.css b/src/styles/app.css index d587533..e78918f 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -102,6 +102,7 @@ two sites read as one brand. bg = cream-100, surface = warm off-white, elevated cards = clean white, border = cream-400 hairline. */ :root { + --superwall-agent-panel-width: 0px; --color-fd-background: #ffffff; /* R3 swap: canvas now white (was cream-100 #fdfef6) */ --color-fd-foreground: var(--color-black-500); --color-fd-muted: #f6f7ef; /* surface: sidebar, muted strips, inline code */ @@ -234,6 +235,15 @@ letter-spacing: -0.01em; } + /* Docked agent mode publishes this desktop push width; it stays 0px + when the widget is closed or overlaying on smaller screens. */ + #nd-docs-layout, + #nd-notebook-layout, + #nd-home-layout { + margin-right: var(--superwall-agent-panel-width, 0px); + transition: margin-right 0.2s ease-out; + } + h1, h2, h3, @@ -341,38 +351,6 @@ height: 2.25rem; } -/* Agent chat widget (superwall.com AgentChatWidget port). Paper-flat Intercom - pop-in — border only, no shadow, no rounding; panel + teaser fade/rise from - the corner, gated by data-open / data-visible. */ -[data-agent-panel], -[data-agent-teaser] { - transform-origin: bottom right; - transition: - opacity 0.2s ease-out, - transform 0.2s ease-out, - visibility 0s linear 0.2s; - opacity: 0; - transform: translateY(0.5rem) scale(0.98); - visibility: hidden; - pointer-events: none; -} -[data-agent-widget][data-open="true"] [data-agent-panel], -[data-agent-teaser][data-visible] { - transition: - opacity 0.2s ease-out, - transform 0.2s ease-out; - opacity: 1; - transform: none; - visibility: visible; - pointer-events: auto; -} -[data-agent-panel] iframe { - display: block; - width: 100%; - height: 100%; - border: 0; -} - /* Theme toggle — design pass. Fumadocs' ThemeSwitch ships a fat rounded pill of two icon segments (sun | moon) where the active one gets `bg-fd-accent`. We want a compact square hairline control with small pixel sun/moon. The sun/moon