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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,6 @@ dist/
.env.local
.DS_Store
coverage/
.astro/
.vercel/
package-lock.json
18 changes: 12 additions & 6 deletions packages/widget/src/dom/panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const CLOSE_ICON = `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">

export interface PanelOptions {
position: 'bottom-right' | 'bottom-left';
inline?: boolean;
branding: {
name: string;
avatar?: string;
Expand All @@ -25,6 +26,9 @@ export class Panel {
constructor(root: ShadowRoot, options: PanelOptions) {
this.el = document.createElement('div');
this.el.className = 'cc-panel';
if (options.inline) {
this.el.classList.add('cc-inline');
}
if (options.position === 'bottom-left') {
this.el.classList.add('cc-left');
}
Expand All @@ -51,12 +55,14 @@ export class Panel {
info.innerHTML = `<div class="cc-header-name">${this.escapeHtml(options.branding.name)}</div><div class="cc-header-subtitle">${this.escapeHtml(options.branding.subtitle)}</div>`;
header.appendChild(info);

const closeBtn = document.createElement('button');
closeBtn.className = 'cc-header-close';
closeBtn.setAttribute('aria-label', 'Close chat');
closeBtn.innerHTML = CLOSE_ICON;
closeBtn.addEventListener('click', options.onClose);
header.appendChild(closeBtn);
if (!options.inline) {
const closeBtn = document.createElement('button');
closeBtn.className = 'cc-header-close';
closeBtn.setAttribute('aria-label', 'Close chat');
closeBtn.innerHTML = CLOSE_ICON;
closeBtn.addEventListener('click', options.onClose);
header.appendChild(closeBtn);
}

this.el.appendChild(header);

Expand Down
14 changes: 10 additions & 4 deletions packages/widget/src/dom/shadow.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import widgetStyles from '../styles/widget.css?inline';

export function createShadowRoot(): ShadowRoot {
export function createShadowRoot(container?: HTMLElement): ShadowRoot {
const host = document.createElement('div');
host.id = 'chatcops-root';
document.body.appendChild(host);

if (container) {
container.appendChild(host);
} else {
document.body.appendChild(host);
}

const shadow = host.attachShadow({ mode: 'open' });

Expand All @@ -14,7 +19,8 @@ export function createShadowRoot(): ShadowRoot {
return shadow;
}

export function destroyShadowRoot(): void {
const host = document.getElementById('chatcops-root');
export function destroyShadowRoot(container?: HTMLElement): void {
const searchRoot = container ?? document.body;
const host = searchRoot.querySelector('#chatcops-root') ?? document.getElementById('chatcops-root');
if (host) host.remove();
}
12 changes: 11 additions & 1 deletion packages/widget/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Widget, type WidgetConfig } from './widget.js';

export type { WidgetConfig } from './widget.js';
export { Widget, type WidgetConfig } from './widget.js';

let instance: Widget | null = null;

Expand Down Expand Up @@ -44,8 +44,13 @@ function autoInit(): void {
const apiUrl = script.dataset.apiUrl;
if (!apiUrl) return;

const mode = script.dataset.mode as 'popup' | 'inline' | undefined;
const container = script.dataset.container;

ChatCops.init({
apiUrl,
mode: mode || undefined,
container: container || undefined,
theme: {
accent: script.dataset.accent,
textColor: script.dataset.textColor,
Expand All @@ -66,6 +71,11 @@ function autoInit(): void {
delay: script.dataset.welcomeBubbleDelay ? parseInt(script.dataset.welcomeBubbleDelay, 10) : undefined,
}
: undefined,
autoOpen: script.dataset.autoOpen !== undefined
? (script.dataset.autoOpen === '' || script.dataset.autoOpen === 'true'
? true
: parseInt(script.dataset.autoOpen, 10) || true)
: undefined,
placeholder: script.dataset.placeholder,
locale: script.dataset.locale,
});
Expand Down
24 changes: 22 additions & 2 deletions packages/widget/src/styles/widget.css
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,26 @@
left: 20px;
}

/* Inline mode */
.cc-panel.cc-inline {
position: relative;
bottom: auto;
right: auto;
left: auto;
width: 100%;
height: 100%;
max-height: 100%;
z-index: auto;
opacity: 1;
transform: none;
pointer-events: auto;
transition: none;
}

.cc-panel.cc-inline.cc-visible {
transform: none;
}

/* Panel header */
.cc-header {
display: flex;
Expand Down Expand Up @@ -451,7 +471,7 @@

/* Mobile */
@media (max-width: 768px) {
.cc-panel {
.cc-panel:not(.cc-inline) {
width: 100%;
height: 100%;
max-height: 100%;
Expand All @@ -462,7 +482,7 @@
border: none;
}

.cc-panel.cc-left {
.cc-panel.cc-left:not(.cc-inline) {
left: 0;
}

Expand Down
78 changes: 62 additions & 16 deletions packages/widget/src/widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import type { MessageData } from './dom/messages.js';

export interface WidgetConfig {
apiUrl: string;
mode?: 'popup' | 'inline';
container?: string | HTMLElement;
theme?: {
accent?: string;
textColor?: string;
Expand All @@ -33,6 +35,7 @@ export interface WidgetConfig {
persistHistory?: boolean;
maxMessages?: number;
pageContext?: boolean;
autoOpen?: boolean | number;
locale?: string;
strings?: Partial<WidgetLocaleStrings>;
onOpen?: () => void;
Expand All @@ -47,7 +50,7 @@ type WidgetEventHandler = (...args: unknown[]) => void;
export class Widget {
private config: WidgetConfig;
private shadow!: ShadowRoot;
private fab!: FAB;
private fab?: FAB;
private panel!: Panel;
private bubble?: WelcomeBubble;
private client: ChatClient;
Expand All @@ -57,6 +60,11 @@ export class Widget {
private messages: MessageData[] = [];
private isStreaming = false;
private eventHandlers = new Map<WidgetEventType, Set<WidgetEventHandler>>();
private containerEl?: HTMLElement;

get isInline(): boolean {
return this.config.mode === 'inline';
}

constructor(config: WidgetConfig) {
this.config = config;
Expand All @@ -66,22 +74,45 @@ export class Widget {
this.conversationId = this.storage.getSessionId();
}

private resolveContainer(): HTMLElement {
if (!this.config.container) {
throw new Error('ChatCops: "container" is required when mode is "inline"');
}
if (typeof this.config.container === 'string') {
const el = document.querySelector(this.config.container);
if (!el) {
throw new Error(`ChatCops: container element not found: ${this.config.container}`);
}
return el as HTMLElement;
}
return this.config.container;
}

init(): void {
this.shadow = createShadowRoot();
if (this.isInline) {
this.containerEl = this.resolveContainer();
this.shadow = createShadowRoot(this.containerEl);
} else {
this.shadow = createShadowRoot();
}

if (this.config.theme) {
applyTheme(this.shadow, this.config.theme);
}

const position = this.config.theme?.position ?? 'bottom-right';

this.fab = new FAB(this.shadow, {
position,
onClick: () => this.toggle(),
});
// FAB — popup mode only
if (!this.isInline) {
this.fab = new FAB(this.shadow, {
position,
onClick: () => this.toggle(),
});
}

this.panel = new Panel(this.shadow, {
position,
inline: this.isInline,
branding: {
name: this.config.branding?.name ?? 'AI Assistant',
avatar: this.config.branding?.avatar,
Expand Down Expand Up @@ -117,8 +148,8 @@ export class Widget {
this.panel.addMessage(welcomeMsg);
}

// Welcome bubble
if (this.config.welcomeBubble) {
// Welcome bubble — popup mode only
if (!this.isInline && this.config.welcomeBubble) {
this.bubble = new WelcomeBubble(this.shadow, {
text: this.config.welcomeBubble.text,
delay: this.config.welcomeBubble.delay,
Expand All @@ -127,20 +158,35 @@ export class Widget {
onClick: () => this.open(),
});
}

// Inline mode — show panel immediately
if (this.isInline) {
this.panel.show();
}

// Auto-open — popup mode only
if (!this.isInline && this.config.autoOpen !== undefined && this.config.autoOpen !== false) {
const delay = typeof this.config.autoOpen === 'number' ? this.config.autoOpen : 0;
setTimeout(() => this.open(), delay);
}
}

open(): void {
this.panel.show();
this.fab.setOpen(true);
this.fab.hideBadge();
this.bubble?.hide();
if (!this.isInline) {
this.fab?.setOpen(true);
this.fab?.hideBadge();
this.bubble?.hide();
}
this.config.onOpen?.();
this.emit('open');
}

close(): void {
this.panel.hide();
this.fab.setOpen(false);
if (!this.isInline) {
this.fab?.setOpen(false);
}
this.config.onClose?.();
this.emit('close');
}
Expand All @@ -156,8 +202,8 @@ export class Widget {
destroy(): void {
this.bubble?.destroy();
this.panel.destroy();
this.fab.destroy();
destroyShadowRoot();
this.fab?.destroy();
destroyShadowRoot(this.containerEl);
this.eventHandlers.clear();
this.messages = [];
}
Expand Down Expand Up @@ -242,8 +288,8 @@ export class Widget {
this.config.onMessage?.(assistantMsg);
this.emit('message', assistantMsg);

if (!this.panel.isVisible) {
this.fab.showBadge();
if (!this.isInline && !this.panel.isVisible) {
this.fab?.showBadge();
}
}
} catch (err) {
Expand Down
1 change: 1 addition & 0 deletions packages/widget/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export default defineConfig({
rollupOptions: {
output: {
inlineDynamicImports: true,
exports: 'named',
},
},
},
Expand Down
Loading