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
10 changes: 10 additions & 0 deletions .changeset/persist-prechat-userdata.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@chatcops/widget": patch
---

Persist and restore pre-chat `userData` across widget recreation and send `userData` with every request.

- Persist pre-chat `userData` in `sessionStorage` alongside the completion flag.
- Restore pre-chat `userData` when a widget instance initializes so the welcome message greets returning visitors by name after `destroy()` and recreation in the same tab.
- Send `userData` on every chat request instead of only the first request.
- When `preChatForm` is disabled, do not restore, store, or send form answers.
27 changes: 26 additions & 1 deletion packages/widget/src/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,36 @@ export class ConversationStorage {
}
}

setPreChatCompleted(sessionId: string): void {
setPreChatCompleted(sessionId: string, data?: Record<string, string>): void {
try {
sessionStorage.setItem(`chatcops-prechat-${sessionId}`, 'true');
} catch {
// Storage unavailable
}
if (data) {
this.setPreChatData(sessionId, data);
}
}

getPreChatData(sessionId: string): Record<string, string> | null {
try {
const raw = sessionStorage.getItem(`chatcops-prechat-data-${sessionId}`);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
return null;
}
return parsed as Record<string, string>;
} catch {
return null;
}
}

setPreChatData(sessionId: string, data: Record<string, string>): void {
try {
sessionStorage.setItem(`chatcops-prechat-data-${sessionId}`, JSON.stringify(data));
} catch {
// Storage unavailable
}
}
}
27 changes: 16 additions & 11 deletions packages/widget/src/widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,6 @@ export class Widget {
private conversationId: string;
private messages: MessageData[] = [];
private userData?: Record<string, string>;
private userDataSent = false;
private isStreaming = false;
private destroyed = false;
private abortController?: AbortController;
Expand Down Expand Up @@ -191,6 +190,13 @@ export class Widget {
}
}

if (this.config.preChatForm?.enabled) {
const savedUserData = this.storage.getPreChatData(this.conversationId);
if (savedUserData) {
this.userData = savedUserData;
}
}

// Show pre-chat form or messages
const shouldShowPreChat = this.config.preChatForm?.enabled
&& !this.isPreChatCompleted()
Expand Down Expand Up @@ -268,6 +274,7 @@ export class Widget {
destroyShadowRoot(this.containerEl);
this.eventHandlers.clear();
this.messages = [];
this.userData = undefined;
}

on(event: WidgetEventType, handler: WidgetEventHandler): void {
Expand All @@ -290,8 +297,8 @@ export class Widget {
return this.storage.isPreChatCompleted(this.conversationId);
}

private setPreChatCompleted(): void {
this.storage.setPreChatCompleted(this.conversationId);
private setPreChatCompleted(data?: Record<string, string>): void {
this.storage.setPreChatCompleted(this.conversationId, data);
}

private showPreChatForm(): void {
Expand All @@ -310,8 +317,9 @@ export class Widget {
}

private handlePreChatSubmit(data: Record<string, string>): void {
if (!this.config.preChatForm?.enabled) return;
this.userData = data;
this.setPreChatCompleted();
this.setPreChatCompleted(data);

this.preChatFormEl?.destroy();
this.preChatFormEl = undefined;
Expand Down Expand Up @@ -432,7 +440,6 @@ export class Widget {
status: 'streaming',
};

const includeUserData = !!(this.userData && !this.userDataSent);
let firstChunk = true;
let requestCompleted = false;
let hasError = false;
Expand All @@ -442,7 +449,7 @@ export class Widget {

try {
for await (const chunk of this.client.sendMessage(
this.createRequest(text, options, includeUserData),
this.createRequest(text, options),
{ signal: abortController.signal }
)) {
if (this.destroyed) break;
Expand Down Expand Up @@ -542,9 +549,6 @@ export class Widget {
}

if (!this.destroyed) {
if (includeUserData && requestCompleted) {
this.userDataSent = true;
}
this.isStreaming = false;
this.panel.input.setDisabled(false);
this.panel.input.focus();
Expand All @@ -555,13 +559,14 @@ export class Widget {

private createRequest(
text: string,
options: SendRequestOptions,
includeUserData: boolean
options: SendRequestOptions
): WidgetChatRequest {
const pageContext = this.config.pageContext !== false
? { url: window.location.href, title: document.title }
: undefined;

const includeUserData = Boolean(this.config.preChatForm?.enabled && this.userData);

return {
conversationId: this.conversationId,
message: text,
Expand Down
43 changes: 42 additions & 1 deletion packages/widget/tests/storage.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { ConversationStorage } from '../src/storage.js';

describe('ConversationStorage', () => {
Expand All @@ -7,6 +7,10 @@ describe('ConversationStorage', () => {
sessionStorage.clear();
});

afterEach(() => {
vi.restoreAllMocks();
});

it('returns null when no conversation saved', () => {
const storage = new ConversationStorage('test');
expect(storage.load()).toBeNull();
Expand Down Expand Up @@ -55,4 +59,41 @@ describe('ConversationStorage', () => {
storage1.save({ id: 'conv-a', messages: [], updatedAt: Date.now() });
expect(storage2.load()).toBeNull();
});

it('persists and restores pre-chat completion and userData', () => {
const storage = new ConversationStorage();
expect(storage.isPreChatCompleted('session-1')).toBe(false);
expect(storage.getPreChatData('session-1')).toBeNull();

storage.setPreChatCompleted('session-1', { name: 'Alice', email: 'alice@example.com' });
expect(storage.isPreChatCompleted('session-1')).toBe(true);
expect(storage.getPreChatData('session-1')).toEqual({ name: 'Alice', email: 'alice@example.com' });
});

it('swallows storage errors on pre-chat operations', () => {
const storage = new ConversationStorage();

const setItemSpy = vi.spyOn(sessionStorage, 'setItem').mockImplementation(() => {
throw new Error('QuotaExceeded');
});
expect(() => storage.setPreChatCompleted('session-2', { name: 'Bob' })).not.toThrow();
expect(() => storage.setPreChatData('session-2', { name: 'Bob' })).not.toThrow();
setItemSpy.mockRestore();

const getItemSpy = vi.spyOn(sessionStorage, 'getItem').mockImplementation(() => {
throw new Error('SecurityError');
});
expect(storage.isPreChatCompleted('session-2')).toBe(false);
expect(storage.getPreChatData('session-2')).toBeNull();
getItemSpy.mockRestore();
});

it('returns null when pre-chat data in storage is malformed', () => {
const storage = new ConversationStorage();
sessionStorage.setItem('chatcops-prechat-data-malformed', 'not-valid-json');
expect(storage.getPreChatData('malformed')).toBeNull();

sessionStorage.setItem('chatcops-prechat-data-array', JSON.stringify(['not', 'an', 'object']));
expect(storage.getPreChatData('array')).toBeNull();
});
});
164 changes: 163 additions & 1 deletion packages/widget/tests/widget.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Widget } from '../src/widget.js';
import { ConversationStorage } from '../src/storage.js';

type WidgetInternals = {
client: {
Expand All @@ -19,6 +20,7 @@ type WidgetInternals = {
handleSend(text: string): Promise<void>;
handleRetry(messageId: string): Promise<void>;
handleRegenerate(messageId: string): Promise<void>;
handlePreChatSubmit(data: Record<string, string>): void;
};

function getInternals(widget: Widget): WidgetInternals {
Expand All @@ -32,7 +34,10 @@ describe('Widget', () => {
sessionStorage.clear();
});

afterEach(() => vi.useRealTimers());
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});

it('does not open after destroy when auto-open is delayed', () => {
vi.useFakeTimers();
Expand Down Expand Up @@ -377,4 +382,161 @@ describe('Widget', () => {
expect(signalAbortedBeforeDestroy).toBe(false);
expect(secondSignal?.aborted).toBe(true);
});

it('restores pre-chat userData and greets by name across widget recreation', async () => {
const config = {
apiUrl: 'https://api.test/chat',
preChatForm: {
enabled: true,
fields: [{ name: 'name', label: 'Name', type: 'text' as const, required: true }],
},
};

const widget1 = new Widget(config);
widget1.init();

getInternals(widget1).handlePreChatSubmit({ name: 'Jordan' });

const welcome1 = getInternals(widget1).messages.find((m) => m.id === 'welcome');
expect(welcome1?.content).toBe('Hi Jordan! How can I help you today?');

widget1.destroy();

const widget2 = new Widget(config);
widget2.init();

expect(getInternals(widget2).shadow.querySelector('.cc-prechat')).toBeNull();
const welcome2 = getInternals(widget2).messages.find((m) => m.id === 'welcome');
expect(welcome2?.content).toBe('Hi Jordan! How can I help you today?');

const sendMessage = vi.fn().mockImplementation(async function* () {
yield { content: 'Echo' };
yield { done: true };
});
getInternals(widget2).client.sendMessage = sendMessage;

await getInternals(widget2).handleSend('Hello after recreation');
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(sendMessage.mock.calls[0]?.[0]).toMatchObject({
message: 'Hello after recreation',
userData: { name: 'Jordan' },
});

widget2.destroy();
});

it('sends userData with every request, not just the first', async () => {
const widget = new Widget({
apiUrl: 'https://api.test/chat',
preChatForm: {
enabled: true,
fields: [{ name: 'name', label: 'Name', type: 'text' as const }],
},
});
widget.init();

getInternals(widget).handlePreChatSubmit({ name: 'Taylor', email: 'taylor@example.com' });

const sendMessage = vi.fn().mockImplementation(async function* () {
yield { content: 'Echo' };
yield { done: true };
});
getInternals(widget).client.sendMessage = sendMessage;

await getInternals(widget).handleSend('First query');
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(sendMessage.mock.calls[0]?.[0]).toMatchObject({
message: 'First query',
userData: { name: 'Taylor', email: 'taylor@example.com' },
});

await getInternals(widget).handleSend('Second query');
expect(sendMessage).toHaveBeenCalledTimes(2);
expect(sendMessage.mock.calls[1]?.[0]).toMatchObject({
message: 'Second query',
userData: { name: 'Taylor', email: 'taylor@example.com' },
});

const assistantMessage = getInternals(widget).messages.at(-1);
expect(assistantMessage?.role).toBe('assistant');
await getInternals(widget).handleRegenerate(assistantMessage!.id);
expect(sendMessage).toHaveBeenCalledTimes(3);
expect(sendMessage.mock.calls[2]?.[0]).toMatchObject({
message: 'Second query',
regenerate: true,
userData: { name: 'Taylor', email: 'taylor@example.com' },
});

widget.destroy();
});

it('does not store or send form answers when preChatForm is disabled', async () => {
const storage = new ConversationStorage();
const sessionId = storage.getSessionId();
storage.setPreChatCompleted(sessionId, { name: 'IgnoredUser' });

const widget = new Widget({
apiUrl: 'https://api.test/chat',
preChatForm: {
enabled: false,
fields: [{ name: 'name', label: 'Name', type: 'text' as const }],
},
});
widget.init();

expect(getInternals(widget).shadow.querySelector('.cc-prechat')).toBeNull();
const welcome = getInternals(widget).messages.find((m) => m.id === 'welcome');
expect(welcome?.content).toBe('How can I help you today?');
expect(welcome?.content).not.toContain('IgnoredUser');

const sendMessage = vi.fn().mockImplementation(async function* () {
yield { content: 'Echo' };
yield { done: true };
});
getInternals(widget).client.sendMessage = sendMessage;

await getInternals(widget).handleSend('Test without preChat');
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(sendMessage.mock.calls[0]?.[0].userData).toBeUndefined();

widget.destroy();
});

it('handles storage failures gracefully during init and pre-chat submit', () => {
const getItemSpy = vi.spyOn(sessionStorage, 'getItem').mockImplementation(() => {
throw new Error('SecurityError');
});

const widget1 = new Widget({
apiUrl: 'https://api.test/chat',
preChatForm: {
enabled: true,
fields: [{ name: 'name', label: 'Name', type: 'text' as const }],
},
});
expect(() => widget1.init()).not.toThrow();
widget1.destroy();

getItemSpy.mockRestore();

const widget2 = new Widget({
apiUrl: 'https://api.test/chat',
preChatForm: {
enabled: true,
fields: [{ name: 'name', label: 'Name', type: 'text' as const }],
},
});
widget2.init();

const setItemSpy = vi.spyOn(sessionStorage, 'setItem').mockImplementation(() => {
throw new Error('QuotaExceeded');
});
expect(() => getInternals(widget2).handlePreChatSubmit({ name: 'Morgan' })).not.toThrow();
const welcome = getInternals(widget2).messages.find((m) => m.id === 'welcome');
expect(welcome?.content).toBe('Hi Morgan! How can I help you today?');

widget2.destroy();
setItemSpy.mockRestore();
});
});

Loading