Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 164 additions & 21 deletions src/components/AgentChatWidget.tsx
Original file line number Diff line number Diff line change
@@ -1,36 +1,179 @@
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 <html>), 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";

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<void> | 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<void>((resolve, reject) => {
const existing = document.getElementById(SCRIPT_ID) as HTMLScriptElement | null;
const script = existing ?? document.createElement("script");

if (existing?.dataset.loaded === "true") {
resolve();
return;
}

const finish = () => {
script.dataset.loaded = "true";
resolve();
};
script.addEventListener("load", finish, { once: true });
script.addEventListener(
"error",
() => reject(new Error("Failed to load the Superwall agent widget.")),
{ once: true },
);
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated

if (!existing) {
script.id = SCRIPT_ID;
script.src = WIDGET_SRC;
script.defer = true;
script.dataset.manual = "";
document.body.appendChild(script);
}
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
});

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(() => {
if (document.getElementById(SCRIPT_ID)) return;
const script = document.createElement("script");
script.id = SCRIPT_ID;
script.src = WIDGET_SRC;
script.dataset.zIndex = "40";
document.body.appendChild(script);
// No teardown: the root layout mounts this once for the app's lifetime.
void ensureAgentWidget("floating");
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated

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;
Expand Down
86 changes: 45 additions & 41 deletions src/components/SearchDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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 (
<SearchDialog search={search} onSearchChange={setSearch} isLoading={query.isLoading} {...props}>
Expand All @@ -162,31 +164,33 @@ export function CustomSearchDialog(props: SharedProps) {
<SearchDialogHeader>
<SearchDialogIcon />
<SearchDialogInput />
<button
type="button"
onClick={() => openAgent()}
data-ai-hint={isAskAIHintActive ? "true" : "false"}
className={cn(
"ask-ai-button group relative inline-flex h-8 shrink-0 items-center justify-center gap-2 overflow-hidden bg-fd-foreground px-3.5 text-xs font-semibold uppercase leading-none tracking-[0.12em] text-fd-background transition-[transform,background-color] duration-150 ease-out hover:bg-fd-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fd-ring focus-visible:ring-offset-2 focus-visible:ring-offset-fd-background active:scale-[0.98]",
)}
title="Ask AI (Cmd/Ctrl+I)"
aria-label="Ask AI"
>
<span className="group relative inline-flex shrink-0">
<button
type="button"
onClick={() => openAgent()}
data-ai-hint={isAskAIHintActive ? "true" : "false"}
className={cn(
"ask-ai-button relative inline-flex h-8 items-center justify-center gap-2 overflow-hidden bg-fd-foreground px-3.5 text-xs font-semibold uppercase leading-none tracking-[0.12em] text-fd-background transition-[transform,background-color] duration-150 ease-out hover:bg-fd-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fd-ring focus-visible:ring-offset-2 focus-visible:ring-offset-fd-background active:scale-[0.98]",
)}
aria-label="Ask AI"
aria-describedby="ask-ai-shortcut-tooltip"
>
<span
aria-hidden="true"
className="ask-ai-button-shimmer pointer-events-none absolute inset-0"
/>
<GridIcon name="sparkles" className="relative z-10 size-3.5" />
<span className="relative z-10">Ask AI</span>
</button>
<span
aria-hidden="true"
className="ask-ai-button-shimmer pointer-events-none absolute inset-0"
/>
<GridIcon name="sparkles" className="relative z-10 size-3.5" />
<span className="relative z-10">Ask AI</span>
<span className="pointer-events-none absolute left-1/2 top-full z-20 mt-2 inline-flex -translate-x-1/2 translate-y-1 items-center gap-1 opacity-0 transition-all duration-150 group-hover:-translate-x-1/2 group-hover:translate-y-0 group-hover:opacity-100 group-focus-visible:-translate-x-1/2 group-focus-visible:translate-y-0 group-focus-visible:opacity-100">
<kbd className="inline-flex min-w-5 items-center justify-center border border-fd-border/80 bg-fd-secondary px-1.5 py-0.5 font-mono text-[11px] text-fd-muted-foreground">
</kbd>
<kbd className="inline-flex min-w-5 items-center justify-center border border-fd-border/80 bg-fd-secondary px-1.5 py-0.5 font-mono text-[11px] text-fd-muted-foreground">
I
</kbd>
id="ask-ai-shortcut-tooltip"
role="tooltip"
className="pointer-events-none absolute left-1/2 top-full z-20 mt-2 inline-flex -translate-x-1/2 translate-y-1 items-center gap-0.5 rounded-md border bg-fd-popover px-1.5 py-1 text-sm text-fd-muted-foreground opacity-0 shadow-md transition-all duration-150 group-hover:translate-y-0 group-hover:opacity-100 group-focus-within:translate-y-0 group-focus-within:opacity-100"
>
<kbd className="rounded-md border bg-fd-background px-1.5">⌘</kbd>
<kbd className="rounded-md border bg-fd-background px-1.5">I</kbd>
</span>
</button>
</span>
</SearchDialogHeader>
<SearchDialogList items={query.data !== "empty" ? query.data : undefined} />
<SearchDialogFooter>
Expand Down
37 changes: 5 additions & 32 deletions src/styles/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -232,6 +233,10 @@
body {
font-family: var(--font-sans);
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. */
padding-right: var(--superwall-agent-panel-width, 0px);
transition: padding-right 0.2s ease-out;
}

h1,
Expand Down Expand Up @@ -341,38 +346,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
Expand Down
Loading