diff --git a/apps/web/src/app/(app)/widget/_components/widget-preview.tsx b/apps/web/src/app/(app)/widget/_components/widget-preview.tsx index e85004b..4a752e0 100644 --- a/apps/web/src/app/(app)/widget/_components/widget-preview.tsx +++ b/apps/web/src/app/(app)/widget/_components/widget-preview.tsx @@ -6,6 +6,7 @@ import { HugeiconsIcon } from "@hugeicons/react"; import { ArrowUp02Icon, Cancel01Icon, + RefreshIcon, } from "@hugeicons/core-free-icons"; import { renderMarkdown } from "@/lib/markdown"; import { API_URL } from "@/lib/api"; @@ -33,11 +34,23 @@ export interface PreviewConfig { * Uses the real `/v1/chat` endpoint, so clicking the launcher and chatting * actually exercises the agent end-to-end. */ -export function WidgetPreview({ config }: { config: PreviewConfig }) { - // Default open in the admin preview — you're configuring the panel, not - // the launcher, so showing the panel first is the right context. - const [open, setOpen] = useState(true); +export function WidgetPreview({ + config, + mode, +}: { + config: PreviewConfig; + mode: "floating" | "embedded"; +}) { const themeMode = useResolvedTheme(config.theme); + const [open, setOpen] = useState(true); + + if (mode === "embedded") { + return ( +
+ +
+ ); + } return ( @@ -57,6 +70,200 @@ export function WidgetPreview({ config }: { config: PreviewConfig }) { ); } +function EmbeddedPreview({ + config, + themeMode, +}: { + config: PreviewConfig; + themeMode: "light" | "dark"; +}) { + const dark = themeMode === "dark"; + const previewConversationId = useMemo(() => crypto.randomUUID(), []); + const { + messages, + input, + handleInputChange, + handleSubmit, + status, + append, + setMessages, + } = useChat({ + api: `${API_URL}/v1/chat?ws=${encodeURIComponent(config.workspaceId)}&conv=${previewConversationId}`, + }); + + const waiting = status === "submitted"; + const busy = status === "submitted" || status === "streaming"; + const messagesEnd = useRef(null); + + useEffect(() => { + messagesEnd.current?.scrollIntoView({ + behavior: "smooth", + block: "nearest", + }); + }, [messages, status]); + + return ( +
+
+
+ +
+
+
+ {config.botName} +
+ {config.botSubtitle && ( +
+ {config.botSubtitle} +
+ )} +
+ +
+ +
+ {config.botGreeting} + + {messages.map((m) => ( + + ))} + + {waiting && ( +
+ + + +
+ )} + + {messages.length === 0 && config.suggestions.length > 0 && !busy && ( +
    + {config.suggestions.map((q) => ( +
  • + +
  • + ))} +
+ )} + +
+
+ +
+ + +
+ +
+ powered by + + helia +
+
+ ); +} + function useResolvedTheme(theme: PreviewConfig["theme"]): "light" | "dark" { const [auto, setAuto] = useState<"light" | "dark">("dark"); useEffect(() => { @@ -71,7 +278,13 @@ function useResolvedTheme(theme: PreviewConfig["theme"]): "light" | "dark" { return theme === "auto" ? auto : theme; } -function FakeBrowser({ children }: { children: React.ReactNode }) { +function FakeBrowser({ + children, + fill, +}: { + children: React.ReactNode; + fill?: boolean; +}) { // The frame represents the customer's website chrome — neutral, following // the admin's theme tokens. The widget panel inside is what reacts to the // widget's own light/dark/auto setting. @@ -82,7 +295,12 @@ function FakeBrowser({ children }: { children: React.ReactNode }) {
-
+
{children}
diff --git a/apps/web/src/app/(app)/widget/page.tsx b/apps/web/src/app/(app)/widget/page.tsx index ad6993a..a1d62d8 100644 --- a/apps/web/src/app/(app)/widget/page.tsx +++ b/apps/web/src/app/(app)/widget/page.tsx @@ -89,6 +89,10 @@ export default function WidgetPage() { const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [copied, setCopied] = useState(false); + const [widgetMode, setWidgetMode] = useState<"floating" | "embedded">( + "floating", + ); + const [widgetTarget, setWidgetTarget] = useState("#helia-chat"); useEffect(() => { api @@ -143,7 +147,18 @@ export default function WidgetPage() { typeof window !== "undefined" ? `${window.location.origin}/w.js` : "/w.js"; - const snippet = ``; + const embedAttrs = + widgetMode === "embedded" + ? ` data-mode="embedded" data-target="${widgetTarget}"` + : ""; + const scriptTag = ``; + // For embedded mode, ship the target container alongside the script so + // the snippet works on paste. The container needs an explicit height; + // otherwise the chat collapses to 0 and devs think the widget is broken. + const snippet = + widgetMode === "embedded" + ? `
\n${scriptTag}` + : scriptTag; const save = async () => { setSaving(true); @@ -288,6 +303,28 @@ export default function WidgetPage() {
+ + + setWidgetMode(v as "floating" | "embedded") + } + /> + + {widgetMode === "embedded" && ( + + setWidgetTarget(e.target.value)} + placeholder="#helia-chat" + maxLength={100} + /> + + )}
- +
diff --git a/packages/widget/src/config.ts b/packages/widget/src/config.ts index ad2b58f..4b4c412 100644 --- a/packages/widget/src/config.ts +++ b/packages/widget/src/config.ts @@ -40,3 +40,36 @@ export async function loadRemoteConfig( return null; } } + +/** + * Cache the last good config per workspace in localStorage. Used to paint + * the right brand on the first frame instead of flashing defaults while + * the network call to /v1/widget/config is in flight. + */ +const CACHE_PREFIX = "helia.config."; + +export function readCachedConfig(workspace: string): RemoteConfig | null { + if (typeof window === "undefined") return null; + try { + const raw = window.localStorage.getItem(CACHE_PREFIX + workspace); + if (!raw) return null; + return JSON.parse(raw) as RemoteConfig; + } catch { + return null; + } +} + +export function writeCachedConfig( + workspace: string, + config: RemoteConfig, +): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem( + CACHE_PREFIX + workspace, + JSON.stringify(config), + ); + } catch { + // Quota or privacy mode — silent fallback to defaults next load. + } +} diff --git a/packages/widget/src/conversation.ts b/packages/widget/src/conversation.ts index f19de98..2c2d86d 100644 --- a/packages/widget/src/conversation.ts +++ b/packages/widget/src/conversation.ts @@ -25,6 +25,19 @@ export function getOrCreateConversationId(workspace: string): string { } } +/** + * Forget the current conversation id. Next call to + * `getOrCreateConversationId` mints a fresh one. + */ +export function resetConversationId(workspace: string): void { + if (typeof window === "undefined" || !window.sessionStorage) return; + try { + window.sessionStorage.removeItem(KEY_PREFIX + workspace); + } catch { + // Storage disabled — next read returns a fresh id anyway. + } +} + function uuid(): string { const g = globalThis as { crypto?: Crypto }; if (g.crypto?.randomUUID) { diff --git a/packages/widget/src/i18n.ts b/packages/widget/src/i18n.ts index d7bc2e1..9703785 100644 --- a/packages/widget/src/i18n.ts +++ b/packages/widget/src/i18n.ts @@ -13,6 +13,7 @@ export type Locale = "en" | "fr" | string; type Strings = { openChat: string; closeChat: string; + newChat: string; send: string; messageInput: string; searchingKnowledge: string; @@ -23,6 +24,7 @@ type Strings = { const EN: Strings = { openChat: "Open chat", closeChat: "Close chat", + newChat: "New chat", send: "Send", messageInput: "Message input", searchingKnowledge: "Searching your knowledge…", @@ -33,6 +35,7 @@ const EN: Strings = { const FR: Strings = { openChat: "Ouvrir le chat", closeChat: "Fermer le chat", + newChat: "Nouvelle conversation", send: "Envoyer", messageInput: "Champ de message", searchingKnowledge: "Recherche dans votre base…", diff --git a/packages/widget/src/index.ts b/packages/widget/src/index.ts index d65a84d..8eac506 100644 --- a/packages/widget/src/index.ts +++ b/packages/widget/src/index.ts @@ -45,9 +45,11 @@ function autoMount(): void { const apiUrl = script.getAttribute("data-api-url") ?? undefined; const tokenEndpoint = script.getAttribute("data-token-endpoint") ?? undefined; + const mode = (script.getAttribute("data-mode") as WidgetConfig["mode"]) ?? undefined; + const target = script.getAttribute("data-target") ?? undefined; const boot = () => { - mount({ workspace, apiUrl }); + mount({ workspace, apiUrl, mode, target }); if (tokenEndpoint) void fetchAndSetIdentity(tokenEndpoint); }; diff --git a/packages/widget/src/styles.ts b/packages/widget/src/styles.ts index f0241d1..0d8ac2d 100644 --- a/packages/widget/src/styles.ts +++ b/packages/widget/src/styles.ts @@ -414,9 +414,48 @@ export const baseStyles = /* css */ ` font-weight: 600; } - /* Mobile */ + /* Embedded mode overrides. The panel sizes to its container, and the + responsive rules below key off the container — not the viewport — so + the same chat looks right whether it's a 280px sidebar or a 1000px + full-page card on the customer's site. */ + .panel.embedded { + position: relative !important; + container-type: inline-size; + container-name: helia-panel; + width: 100%; + height: 100%; + max-height: none; + opacity: 1; + pointer-events: auto; + transform: none; + border-radius: var(--helia-radius); + } + + /* Narrow containers (embedded in a sidebar). Tighten chrome so the + header doesn't dominate and the input still fits comfortably. */ + @container helia-panel (max-width: 360px) { + .header { padding: 10px 12px; gap: 8px; } + .header-subtitle { display: none; } + .header-title { font-size: 14px; } + .messages { padding: 12px; gap: 8px; } + .message { max-width: 92%; font-size: 13px; padding: 8px 10px; } + .input { padding: 8px; } + .input input { padding: 8px 12px; font-size: 13px; } + .input button { width: 34px; height: 34px; } + } + + /* Wide containers (full-page embed). Cap the readable column so lines + don't stretch to 1000px, and centre everything. */ + @container helia-panel (min-width: 720px) { + .messages { padding: 24px max(24px, calc((100% - 760px) / 2)); } + .input { padding: 14px max(24px, calc((100% - 760px) / 2)); } + .message { max-width: 78%; } + } + + /* Floating panel on small viewports. Container queries don't help here + because the floating panel sizes against the viewport, not a parent. */ @media (max-width: 480px) { - .panel { + .panel:not(.embedded) { right: 0 !important; left: 0 !important; bottom: 0; @@ -426,4 +465,21 @@ export const baseStyles = /* css */ ` border-radius: 0; } } + + /* Reset button (embedded mode). Sits where .close lives in floating. */ + .reset { + background: transparent; + border: none; + color: #ffffff; + cursor: pointer; + padding: 4px; + display: flex; + border-radius: 6px; + opacity: 0.85; + flex-shrink: 0; + } + .reset:hover { opacity: 1; background: rgba(255, 255, 255, 0.12); } + .reset svg { width: 18px; height: 18px; } + .embedded .close { display: none; } + .panel:not(.embedded) .reset { display: none; } `; diff --git a/packages/widget/src/types.ts b/packages/widget/src/types.ts index dd7cc78..d4df864 100644 --- a/packages/widget/src/types.ts +++ b/packages/widget/src/types.ts @@ -1,6 +1,8 @@ export interface WidgetConfig { workspace: string; apiUrl?: string; + mode?: "floating" | "embedded"; + target?: string; botName?: string; greeting?: string; } diff --git a/packages/widget/src/widget.ts b/packages/widget/src/widget.ts index 37407e3..3c50dbd 100644 --- a/packages/widget/src/widget.ts +++ b/packages/widget/src/widget.ts @@ -1,8 +1,16 @@ import { baseStyles } from "./styles"; import { streamChat } from "./stream"; -import { loadRemoteConfig, type RemoteConfig } from "./config"; +import { + loadRemoteConfig, + readCachedConfig, + writeCachedConfig, + type RemoteConfig, +} from "./config"; import { getIdentity } from "./identity"; -import { getOrCreateConversationId } from "./conversation"; +import { + getOrCreateConversationId, + resetConversationId, +} from "./conversation"; import { renderMarkdown } from "./markdown"; import { strings, type Locale } from "./i18n"; import type { ChatMessage, WidgetConfig, WidgetHandle } from "./types"; @@ -52,6 +60,34 @@ export function mount(config: WidgetConfig): WidgetHandle { } const apiUrl = config.apiUrl ?? defaultApiUrl(); + const requestedEmbedded = config.mode === "embedded"; + + // In embedded mode, resolve the target element. + let targetEl: Element | null = null; + if (requestedEmbedded) { + if (!config.target) { + console.warn( + "[helia] embedded mode requires a `target` selector. Falling back to floating.", + ); + targetEl = null; + } else { + let selectorValid = true; + try { + targetEl = document.querySelector(config.target); + } catch { + selectorValid = false; + console.warn( + `[helia] embedded mode: target "${config.target}" is not a valid selector. Falling back to floating.`, + ); + } + if (selectorValid && !targetEl) { + console.warn( + `[helia] embedded mode: target "${config.target}" not found. Falling back to floating.`, + ); + } + } + } + const embedded = requestedEmbedded && targetEl !== null; let botName = config.botName ?? "Assistant"; let greeting = config.greeting ?? "Hi, how can I help?"; @@ -63,7 +99,11 @@ export function mount(config: WidgetConfig): WidgetHandle { const host = document.createElement("div"); host.id = HOST_ID; host.dataset.workspace = config.workspace; - document.body.appendChild(host); + if (targetEl) { + targetEl.appendChild(host); + } else { + document.body.appendChild(host); + } const root = host.attachShadow({ mode: "open" }); @@ -73,17 +113,20 @@ export function mount(config: WidgetConfig): WidgetHandle { const hostEl = root.host as HTMLElement; - // --- Launcher - const launcher = document.createElement("button"); - launcher.className = "launcher right"; - launcher.type = "button"; - launcher.setAttribute("aria-label", t.openChat); - launcher.innerHTML = avatarMarkup(botAvatar); - root.appendChild(launcher); + // --- Launcher (floating mode only) + let launcher: HTMLButtonElement | null = null; + if (!embedded) { + launcher = document.createElement("button"); + launcher.className = "launcher right"; + launcher.type = "button"; + launcher.setAttribute("aria-label", t.openChat); + launcher.innerHTML = avatarMarkup(botAvatar); + root.appendChild(launcher); + } // --- Panel const panel = document.createElement("div"); - panel.className = "panel right"; + panel.className = embedded ? "panel embedded" : "panel right"; panel.setAttribute("role", "dialog"); panel.setAttribute("aria-label", `${botName} chat`); panel.innerHTML = ` @@ -93,6 +136,7 @@ export function mount(config: WidgetConfig): WidgetHandle {
${escapeHtml(botName)}
+
@@ -119,6 +163,7 @@ export function mount(config: WidgetConfig): WidgetHandle { const input = panel.querySelector("input") as HTMLInputElement; const sendBtn = panel.querySelector('button[type="submit"]') as HTMLButtonElement; const closeBtn = panel.querySelector(".close") as HTMLButtonElement; + const resetBtn = panel.querySelector(".reset") as HTMLButtonElement; const titleEl = panel.querySelector(".header-title") as HTMLElement; const subtitleEl = panel.querySelector(".header-subtitle") as HTMLElement; @@ -127,19 +172,42 @@ export function mount(config: WidgetConfig): WidgetHandle { messagesEl.appendChild(suggestionsEl); const state: State = { - open: false, + open: embedded, streaming: false, messages: [], }; - const greetingEl = appendMessage("assistant", greeting); + // Hold the latest suggestion list so we can re-render when the + // conversation resets to empty. + let currentSuggestions: string[] = []; + + let greetingEl = appendMessage("assistant", greeting); + + // First paint: if we have a cached config from a previous load, apply it + // immediately so the right brand/wording shows up on the first frame + // instead of flashing defaults while the network call resolves. + const cached = readCachedConfig(config.workspace); + if (cached) applyRemote(cached); - // Fetch the workspace config and apply it. void loadRemoteConfig(apiUrl, config.workspace).then((remote) => { if (!remote) return; applyRemote(remote); + writeCachedConfig(config.workspace, remote); }); + // DX hint: most common embed mistake is forgetting to give the target + // container a height. Check after a paint so flex parents have settled. + if (embedded && targetEl instanceof HTMLElement) { + const el = targetEl; + requestAnimationFrame(() => { + if (el.isConnected && el.offsetHeight === 0) { + console.warn( + `[helia] embed target "${config.target}" has 0 height. Give it an explicit height (e.g. style="height: 600px") or place it in a flex/grid cell with a defined size.`, + ); + } + }); + } + function applyRemote(remote: RemoteConfig): void { hostEl.style.setProperty("--helia-primary", remote.theme.primary); if (typeof remote.theme.radius === "number") { @@ -154,9 +222,14 @@ export function mount(config: WidgetConfig): WidgetHandle { botAvatar = remote.bot.avatar ?? null; locale = remote.workspace.locale; t = strings(locale); - launcher.setAttribute("aria-label", t.openChat); + if (launcher) { + launcher.setAttribute("aria-label", t.openChat); + launcher.innerHTML = avatarMarkup(botAvatar); + } const closeBtn = panel.querySelector(".close"); if (closeBtn) closeBtn.setAttribute("aria-label", t.closeChat); + resetBtn.setAttribute("aria-label", t.newChat); + resetBtn.setAttribute("title", t.newChat); input.setAttribute("aria-label", t.messageInput); sendBtn.setAttribute("aria-label", t.send); titleEl.textContent = botName; @@ -165,15 +238,15 @@ export function mount(config: WidgetConfig): WidgetHandle { panel.setAttribute("aria-label", `${botName} chat`); // Refresh the icons now that we know what the customer chose. - launcher.innerHTML = avatarMarkup(botAvatar); const avatarSlot = panel.querySelector(".avatar"); if (avatarSlot) { avatarSlot.innerHTML = avatarMarkup(botAvatar); } + currentSuggestions = remote.bot.suggestions ?? []; if (state.messages.length === 0) { greetingEl.textContent = greeting; - renderSuggestions(remote.bot.suggestions ?? []); + renderSuggestions(currentSuggestions); } } @@ -198,6 +271,22 @@ export function mount(config: WidgetConfig): WidgetHandle { suggestionsEl.classList.remove("hidden"); } + function resetConversation(): void { + if (state.streaming) return; + state.messages = []; + resetConversationId(config.workspace); + // Wipe everything from the messages area and re-seed with the greeting + // and any configured suggestions, so the user sees the same first + // frame they got when they originally opened the chat. + messagesEl.innerHTML = ""; + messagesEl.appendChild(suggestionsEl); + greetingEl = appendMessage("assistant", greeting); + renderSuggestions(currentSuggestions); + input.value = ""; + sendBtn.disabled = true; + input.focus(); + } + function applyThemeMode(mode: RemoteConfig["theme"]["mode"]): void { const resolved = resolveTheme(mode); hostEl.dataset.mode = resolved; @@ -206,16 +295,24 @@ export function mount(config: WidgetConfig): WidgetHandle { function applyPosition( pos: "bottom-right" | "bottom-left" | undefined, ): void { + if (embedded) return; const next: "left" | "right" = pos === "bottom-left" ? "left" : "right"; - launcher.classList.remove("left", "right"); - launcher.classList.add(next); + if (launcher) { + launcher.classList.remove("left", "right"); + launcher.classList.add(next); + } panel.classList.remove("left", "right"); panel.classList.add(next); } // --- Wiring - launcher.addEventListener("click", () => setOpen(true)); - closeBtn.addEventListener("click", () => setOpen(false)); + if (launcher) { + launcher.addEventListener("click", () => setOpen(true)); + } + if (!embedded) { + closeBtn.addEventListener("click", () => setOpen(false)); + } + resetBtn.addEventListener("click", () => resetConversation()); input.addEventListener("input", () => { sendBtn.disabled = state.streaming || input.value.trim().length === 0; @@ -226,14 +323,17 @@ export function mount(config: WidgetConfig): WidgetHandle { void send(); }); - document.addEventListener("keydown", (e) => { - if (e.key === "Escape" && state.open) setOpen(false); - }); + if (!embedded) { + document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && state.open) setOpen(false); + }); + } function setOpen(open: boolean): void { + if (embedded) return; state.open = open; panel.classList.toggle("open", open); - launcher.classList.toggle("hidden", open); + if (launcher) launcher.classList.toggle("hidden", open); if (open) { setTimeout(() => input.focus(), 180); } @@ -478,6 +578,17 @@ function escapeAttr(s: string): string { .replace(/ + + + + `; +} + function closeIcon(): string { return /* html */ `