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.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 }) {
-
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 {
+
@@ -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 */ `