Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
230 changes: 224 additions & 6 deletions apps/web/src/app/(app)/widget/_components/widget-preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
<div className="h-[580px] w-full">
<EmbeddedPreview config={config} themeMode={themeMode} />
</div>
);
}

return (
<FakeBrowser>
Expand All @@ -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<HTMLDivElement>(null);

useEffect(() => {
messagesEnd.current?.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
}, [messages, status]);

return (
<div
className={cn(
"flex h-full w-full flex-col overflow-hidden shadow-2xl",
dark ? "bg-[#161616] text-zinc-100" : "bg-white text-zinc-900",
)}
style={{ borderRadius: config.radius }}
>
<header
className="flex items-start gap-3 px-4 py-3.5 text-white"
style={{ background: config.primary }}
>
<div className="flex h-7 w-7 flex-shrink-0 items-center justify-center overflow-hidden rounded-full bg-white/20">
<AvatarMark avatar={config.botAvatar} size={16} />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-[15px] font-semibold leading-tight">
{config.botName}
</div>
{config.botSubtitle && (
<div className="mt-0.5 truncate text-xs opacity-90">
{config.botSubtitle}
</div>
)}
</div>
<button
type="button"
data-shadcn=""
onClick={() => setMessages([])}
disabled={busy || messages.length === 0}
aria-label="New chat"
title="New chat"
className="rounded p-1 opacity-80 hover:bg-white/10 hover:opacity-100 disabled:cursor-not-allowed disabled:opacity-40"
>
<HugeiconsIcon icon={RefreshIcon} size={16} />
</button>
</header>

<div className="flex-1 space-y-2 overflow-y-auto px-3 py-3 text-sm">
<AssistantBubble dark={dark}>{config.botGreeting}</AssistantBubble>

{messages.map((m) => (
<MessageRender
key={m.id}
message={m}
primary={config.primary}
dark={dark}
/>
))}

{waiting && (
<div
className={cn(
"mr-auto inline-flex items-center gap-1 rounded-2xl rounded-bl-sm px-3 py-2",
dark ? "bg-zinc-800" : "bg-zinc-100",
)}
>
<Dot delay={0} />
<Dot delay={150} />
<Dot delay={300} />
</div>
)}

{messages.length === 0 && config.suggestions.length > 0 && !busy && (
<ul className="space-y-1.5 pt-1">
{config.suggestions.map((q) => (
<li key={q}>
<button
type="button"
data-shadcn=""
onClick={() => void append({ role: "user", content: q })}
className={cn(
"w-full rounded-2xl border px-3 py-2 text-left text-[13px] leading-snug transition-colors",
dark
? "border-zinc-700 hover:bg-zinc-800"
: "border-zinc-200 hover:bg-zinc-50",
)}
>
{q}
</button>
</li>
))}
</ul>
)}

<div ref={messagesEnd} />
</div>

<form
onSubmit={handleSubmit}
className={cn(
"flex items-center gap-2 border-t px-3 py-2.5",
dark ? "border-zinc-800" : "border-zinc-200",
)}
>
<input
value={input}
onChange={handleInputChange}
placeholder={config.botPlaceholder}
disabled={busy}
className={cn(
"flex-1 rounded-full border bg-transparent px-3 py-1.5 text-[13px] outline-none focus:ring-2 focus:ring-offset-1",
dark
? "border-zinc-700 focus:ring-zinc-600 focus:ring-offset-zinc-900"
: "border-zinc-200 focus:ring-zinc-300 focus:ring-offset-white",
)}
/>
<button
type="submit"
data-shadcn=""
disabled={busy || input.trim().length === 0}
className="flex h-8 w-8 items-center justify-center rounded-full text-white disabled:opacity-50"
style={{ background: config.primary }}
aria-label="Send"
>
<HugeiconsIcon icon={ArrowUp02Icon} size={14} />
</button>
</form>

<div
className={cn(
"flex items-center justify-center gap-1.5 py-2 font-mono text-[11px] leading-none tracking-wide",
dark ? "text-zinc-500" : "text-zinc-400",
)}
>
<span>powered by</span>
<svg
viewBox="0 0 32 32"
aria-hidden="true"
className="h-3 w-3 flex-shrink-0"
>
<path
d="M 4 22 A 12 12 0 0 1 28 22"
fill="none"
stroke="currentColor"
strokeWidth="3.4"
strokeLinecap="round"
opacity="1"
/>
<path
d="M 9 22 A 7 7 0 0 1 23 22"
fill="none"
stroke="currentColor"
strokeWidth="3.4"
strokeLinecap="round"
opacity="0.55"
/>
<path
d="M 13.5 22 A 2.5 2.5 0 0 1 18.5 22"
fill="none"
stroke="currentColor"
strokeWidth="3.4"
strokeLinecap="round"
opacity="0.28"
/>
</svg>
<span className="font-semibold">helia</span>
</div>
</div>
);
}

function useResolvedTheme(theme: PreviewConfig["theme"]): "light" | "dark" {
const [auto, setAuto] = useState<"light" | "dark">("dark");
useEffect(() => {
Expand All @@ -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.
Expand All @@ -82,7 +295,12 @@ function FakeBrowser({ children }: { children: React.ReactNode }) {
<div className="h-2 w-2 rounded-full bg-muted-foreground/40" />
<div className="h-2 w-2 rounded-full bg-muted-foreground/40" />
</div>
<div className="relative flex h-[580px] items-end justify-center p-4">
<div
className={cn(
"flex h-[580px]",
fill ? "flex-col" : "items-end justify-center p-4",
)}
>
{children}
</div>
</div>
Expand Down
41 changes: 39 additions & 2 deletions apps/web/src/app/(app)/widget/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -143,7 +147,18 @@ export default function WidgetPage() {
typeof window !== "undefined"
? `${window.location.origin}/w.js`
: "/w.js";
const snippet = `<script src="${widgetSrc}" data-workspace="${ws.id}" async></script>`;
const embedAttrs =
widgetMode === "embedded"
? ` data-mode="embedded" data-target="${widgetTarget}"`
: "";
const scriptTag = `<script src="${widgetSrc}" data-workspace="${ws.id}"${embedAttrs} async></script>`;
// 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"
? `<div id="${widgetTarget.replace(/^#/, "")}" style="height: 600px"></div>\n${scriptTag}`
: scriptTag;

const save = async () => {
setSaving(true);
Expand Down Expand Up @@ -288,6 +303,28 @@ export default function WidgetPage() {

<Section title="Layout">
<div className="space-y-4">
<Field label="mode">
<SegGroup
options={[
{ value: "floating", label: "floating" },
{ value: "embedded", label: "embedded" },
]}
value={widgetMode}
onChange={(v) =>
setWidgetMode(v as "floating" | "embedded")
}
/>
</Field>
{widgetMode === "embedded" && (
<Field label="target selector">
<Input
value={widgetTarget}
onChange={(e) => setWidgetTarget(e.target.value)}
placeholder="#helia-chat"
maxLength={100}
/>
</Field>
)}
<Field label="position">
<SegGroup
options={[
Expand Down Expand Up @@ -341,7 +378,7 @@ export default function WidgetPage() {
primary task anyway. */}
<div className="hidden lg:block">
<Section title="Live preview">
<WidgetPreview config={config} />
<WidgetPreview config={config} mode={widgetMode} />
</Section>
</div>

Expand Down
33 changes: 33 additions & 0 deletions packages/widget/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
}
}
13 changes: 13 additions & 0 deletions packages/widget/src/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading
Loading