From 290730cb71b01ff8e6810947059feeabaff2e211 Mon Sep 17 00:00:00 2001 From: Lukas Boehler Date: Sun, 25 Oct 2026 04:53:49 +0100 Subject: [PATCH 1/5] feat(sidekick): the sidekick's rules (context envelope, layout, proactive guards) What the page tells the agent (origin + path only: query and fragment never leave the page; title, onboarding step, the app's data, bounded like the server's sanitiser) as one envelope with Kai Copilot (surface 'sidekick'), overlay / push / sheet by viewport, and when a proactive open may show. Co-Authored-By: Claude Opus 5.5 --- src/GleapSidekickRules.js | 191 +++++++++++++++++++++++++++++++++ src/GleapSidekickRules.test.js | 100 +++++++++++++++++ 2 files changed, 291 insertions(+) create mode 100644 src/GleapSidekickRules.js create mode 100644 src/GleapSidekickRules.test.js diff --git a/src/GleapSidekickRules.js b/src/GleapSidekickRules.js new file mode 100644 index 00000000..bbe12f47 --- /dev/null +++ b/src/GleapSidekickRules.js @@ -0,0 +1,191 @@ +// The sidekick's rules (decision 13): what the page tells the agent and when a +// proactive open may show. Pure: no DOM, no managers, so the tests can pin them. + +export const SIDEKICK_WIDTH = 400; +// Push only when the page keeps at least this much room next to the panel. +export const SIDEKICK_PUSH_MIN_PAGE_WIDTH = 720; +// At or below this width the panel is a full-screen sheet. +export const SIDEKICK_PHONE_MAX_WIDTH = 600; + +// Mirrors the server's caps (Server copilotContext.logic CONTEXT_LIMITS / APP_CONTEXT_LIMITS): +// the server sanitises again, this keeps the request small. +export const SIDEKICK_CONTEXT_LIMITS = { + route: 300, + title: 140, + dataEntries: 30, + dataChars: 24000, + stepNumber: 999, +}; + +// A proactive open the customer closed without writing stays quiet this long. +export const SIDEKICK_DISMISS_MS = 24 * 60 * 60 * 1000; +// Never while the person types: wait for this long without keys, at most TYPING_MAX_WAIT_MS. +export const SIDEKICK_TYPING_IDLE_MS = 4000; +export const SIDEKICK_TYPING_MAX_WAIT_MS = 30000; +// A hidden tab waits this long for the person to come back. +export const SIDEKICK_HIDDEN_MAX_WAIT_MS = 60000; + +const STEP_ID = /^[A-Za-z0-9_-]{1,64}$/; + +const plain = (value, max) => { + if (typeof value !== 'string') { + return undefined; + } + const text = value.replace(/\s+/g, ' ').trim(); + if (!text) { + return undefined; + } + return text.length > max ? text.slice(0, max) : text; +}; + +/** + * The page's address as the agent sees it: origin + path. The query and the + * fragment (tokens, emails, ids ride there) never leave the page. + */ +export const sidekickRouteOf = (href) => { + try { + const url = new URL(href); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return undefined; + } + return plain(url.origin + url.pathname, SIDEKICK_CONTEXT_LIMITS.route); + } catch (e) { + return undefined; + } +}; + +const stepNumberOf = (value) => + typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= SIDEKICK_CONTEXT_LIMITS.stepNumber + ? value + : undefined; + +/** The onboarding step: a string is its title; an object keeps id, title, index (1-based), total. */ +export const sidekickStepOf = (step) => { + if (typeof step === 'string') { + const title = plain(step, SIDEKICK_CONTEXT_LIMITS.title); + return title ? { title } : undefined; + } + if (!step || typeof step !== 'object' || Array.isArray(step)) { + return undefined; + } + const id = typeof step.id === 'string' && STEP_ID.test(step.id.trim()) ? step.id.trim() : undefined; + const title = plain(step.title, SIDEKICK_CONTEXT_LIMITS.title); + const index = stepNumberOf(step.index); + const total = stepNumberOf(step.total); + const out = {}; + if (id) out.id = id; + if (title) out.title = title; + if (index !== undefined) out.index = index; + if (total !== undefined) out.total = total; + return Object.keys(out).length ? out : undefined; +}; + +/** What the app knows (plan, seats, a record id…): JSON-safe entries until the limits. */ +export const sidekickDataOf = (data) => { + if (!data || typeof data !== 'object' || Array.isArray(data)) { + return undefined; + } + const out = {}; + let count = 0; + let size = 0; + for (const key of Object.keys(data)) { + if (count >= SIDEKICK_CONTEXT_LIMITS.dataEntries) { + break; + } + let json; + try { + json = JSON.stringify(data[key]); + } catch (e) { + continue; + } + if (typeof json !== 'string') { + continue; + } + if (size + key.length + json.length > SIDEKICK_CONTEXT_LIMITS.dataChars) { + break; + } + size += key.length + json.length; + count += 1; + out[key] = JSON.parse(json); + } + return count ? out : undefined; +}; + +const STRUCTURED_KEYS = ['title', 'step', 'data']; + +/** + * The app's sidekick context: `{ title?, step?, data? }`. An object with none + * of those keys is taken as `data` (like Gleap.startAgent's bare context). + * null/undefined clears it. + */ +export const normalizeSidekickContext = (context) => { + if (!context || typeof context !== 'object' || Array.isArray(context)) { + return null; + } + const structured = STRUCTURED_KEYS.some((key) => Object.prototype.hasOwnProperty.call(context, key)); + const source = structured ? context : { data: context }; + const out = {}; + const title = plain(source.title, SIDEKICK_CONTEXT_LIMITS.title); + const step = sidekickStepOf(source.step); + const data = sidekickDataOf(source.data); + if (title) out.title = title; + if (step) out.step = step; + if (data) out.data = data; + return Object.keys(out).length ? out : null; +}; + +/** The envelope the panel sends with every message (`surface: 'sidekick'`, one shape with Kai Copilot). */ +export const buildSidekickEnvelope = ({ href, documentTitle, context }) => { + const envelope = { surface: 'sidekick' }; + const route = sidekickRouteOf(href); + const title = (context && context.title) || plain(documentTitle, SIDEKICK_CONTEXT_LIMITS.title); + if (route || title) { + envelope.page = {}; + if (route) envelope.page.route = route; + if (title) envelope.page.title = title; + } + if (context && context.step) envelope.step = context.step; + if (context && context.data) envelope.app = context.data; + return envelope; +}; + +/** overlay (default) · push (only with room for the page) · sheet (phones). */ +export const sidekickModeOf = ({ requested, viewportWidth }) => { + if (!(viewportWidth > SIDEKICK_PHONE_MAX_WIDTH)) { + return 'sheet'; + } + if (requested === 'push' && viewportWidth - SIDEKICK_WIDTH >= SIDEKICK_PUSH_MIN_PAGE_WIDTH) { + return 'push'; + } + return 'overlay'; +}; + +/** A socket `sidekick` message as the SDK uses it, or null. */ +export const proactiveSidekickOf = (data) => { + if (!data || typeof data !== 'object') { + return null; + } + const id = typeof data.id === 'string' && data.id ? data.id.slice(0, 64) : null; + const message = plain(data.message, 500); + const trigger = data.trigger && typeof data.trigger === 'object' ? data.trigger : null; + const triggerKey = + trigger && typeof trigger.type === 'string' && typeof trigger.id === 'string' ? `${trigger.type}:${trigger.id}`.slice(0, 160) : null; + if (!id || !message || !triggerKey) { + return null; + } + const agentId = typeof data.agentId === 'string' && /^[a-f0-9]{24}$/i.test(data.agentId) ? data.agentId : null; + return { id, agentId, message, triggerKey }; +}; + +/** + * Whether a proactive open may show now, or why not: the page allows it, at + * most one per page view, nothing else open, not dismissed in the last day. + * (Typing and a hidden tab wait instead: see the manager.) + */ +export const proactiveRefusalOf = ({ enabled, shownThisPageView, busy, dismissedAt, now }) => { + if (!enabled) return 'disabled'; + if (shownThisPageView) return 'page_view'; + if (busy) return 'busy'; + if (typeof dismissedAt === 'number' && now - dismissedAt < SIDEKICK_DISMISS_MS) return 'dismissed'; + return null; +}; diff --git a/src/GleapSidekickRules.test.js b/src/GleapSidekickRules.test.js new file mode 100644 index 00000000..15c275b0 --- /dev/null +++ b/src/GleapSidekickRules.test.js @@ -0,0 +1,100 @@ +import { + SIDEKICK_CONTEXT_LIMITS, + SIDEKICK_DISMISS_MS, + buildSidekickEnvelope, + normalizeSidekickContext, + proactiveRefusalOf, + proactiveSidekickOf, + sidekickModeOf, + sidekickRouteOf, +} from './GleapSidekickRules'; + +describe('the page address the sidekick sends', () => { + test('query and fragment never leave the page (tokens, emails and ids ride there)', () => { + expect(sidekickRouteOf('https://app.example.com/settings/billing?session=s3cr3t&email=a@b.c#access_token=x')).toBe( + 'https://app.example.com/settings/billing' + ); + expect(sidekickRouteOf('http://localhost:4450/onboarding/step-2#/invite?token=1')).toBe('http://localhost:4450/onboarding/step-2'); + }); + + test('only web pages, and bounded', () => { + expect(sidekickRouteOf('file:///Users/me/secret.html')).toBeUndefined(); + expect(sidekickRouteOf('javascript:alert(1)')).toBeUndefined(); + expect(sidekickRouteOf('not a url')).toBeUndefined(); + expect(sidekickRouteOf('https://a.example.com/' + 'p'.repeat(1000)).length).toBeLessThanOrEqual(SIDEKICK_CONTEXT_LIMITS.route); + }); +}); + +describe('the app context', () => { + test('{ title, step, data }; a plain object is its data (as startAgent)', () => { + expect(normalizeSidekickContext({ plan: 'Pro', seats: 12 })).toEqual({ data: { plan: 'Pro', seats: 12 } }); + expect( + normalizeSidekickContext({ title: ' Billing ', step: { id: 'invite', title: 'Invite your team', index: 3, total: 5 }, data: { plan: 'Pro' } }) + ).toEqual({ title: 'Billing', step: { id: 'invite', title: 'Invite your team', index: 3, total: 5 }, data: { plan: 'Pro' } }); + expect(normalizeSidekickContext({ step: 'Connect your inbox' })).toEqual({ step: { title: 'Connect your inbox' } }); + expect(normalizeSidekickContext(null)).toBeNull(); + expect(normalizeSidekickContext(['a'])).toBeNull(); + }); + + test('bad step ids and numbers are dropped; data stays JSON and bounded', () => { + expect(normalizeSidekickContext({ step: { id: '../x', index: 1.5, total: 5000 } })).toBeNull(); + const many = Object.fromEntries(Array.from({ length: 50 }, (_, index) => [`k${index}`, index])); + expect(Object.keys(normalizeSidekickContext({ data: many }).data).length).toBe(SIDEKICK_CONTEXT_LIMITS.dataEntries); + const big = normalizeSidekickContext({ data: { a: 'x'.repeat(20000), b: 'y'.repeat(20000), fn: () => 1 } }); + expect(Object.keys(big.data)).toEqual(['a']); + }); + + test('the envelope: one shape with Kai Copilot (surface sidekick, page, step, app)', () => { + expect( + buildSidekickEnvelope({ + href: 'https://app.example.com/onboarding?token=t#x', + documentTitle: 'Onboarding · Example', + context: normalizeSidekickContext({ step: { id: 'invite', index: 3, total: 5 }, data: { plan: 'trial' } }), + }) + ).toEqual({ + surface: 'sidekick', + page: { route: 'https://app.example.com/onboarding', title: 'Onboarding · Example' }, + step: { id: 'invite', index: 3, total: 5 }, + app: { plan: 'trial' }, + }); + // The app's own title wins over the document's. + expect(buildSidekickEnvelope({ href: 'https://a.example.com/', documentTitle: 'Doc', context: { title: 'Mine' } }).page.title).toBe('Mine'); + }); +}); + +describe('layout', () => { + test('overlay by default, push only with room for the page, a sheet on phones', () => { + expect(sidekickModeOf({ requested: undefined, viewportWidth: 1440 })).toBe('overlay'); + expect(sidekickModeOf({ requested: 'push', viewportWidth: 1440 })).toBe('push'); + expect(sidekickModeOf({ requested: 'push', viewportWidth: 1000 })).toBe('overlay'); + expect(sidekickModeOf({ requested: 'push', viewportWidth: 390 })).toBe('sheet'); + expect(sidekickModeOf({ requested: undefined, viewportWidth: 600 })).toBe('sheet'); + }); +}); + +describe('proactive opens', () => { + const now = Date.UTC(2026, 9, 25, 9, 0, 0); + const ok = { enabled: true, shownThisPageView: false, busy: false, dismissedAt: null, now }; + + test('a well-formed socket message only', () => { + expect( + proactiveSidekickOf({ id: 'a1', agentId: '6adca6ce3d7fdf51b1584f08', message: ' Want me to do it? ', trigger: { type: 'agent_event', id: 'x:y' } }) + ).toEqual({ id: 'a1', agentId: '6adca6ce3d7fdf51b1584f08', message: 'Want me to do it?', triggerKey: 'agent_event:x:y' }); + expect(proactiveSidekickOf({ id: 'a1', agentId: null, message: 'Hi', trigger: { type: 'skill', id: 's1' } }).agentId).toBeNull(); + expect(proactiveSidekickOf({ id: 'a1', agentId: '../../x', message: 'Hi', trigger: { type: 'skill', id: 's1' } }).agentId).toBeNull(); + expect(proactiveSidekickOf({ id: 'a1', message: '', trigger: { type: 'skill', id: 's1' } })).toBeNull(); + expect(proactiveSidekickOf({ id: 'a1', message: 'Hi' })).toBeNull(); + }); + + test('once per page view, never over something else, never when the page said no', () => { + expect(proactiveRefusalOf(ok)).toBeNull(); + expect(proactiveRefusalOf({ ...ok, enabled: false })).toBe('disabled'); + expect(proactiveRefusalOf({ ...ok, shownThisPageView: true })).toBe('page_view'); + expect(proactiveRefusalOf({ ...ok, busy: true })).toBe('busy'); + }); + + test('closed without writing: quiet for a day', () => { + expect(proactiveRefusalOf({ ...ok, dismissedAt: now - 60 * 1000 })).toBe('dismissed'); + expect(proactiveRefusalOf({ ...ok, dismissedAt: now - SIDEKICK_DISMISS_MS - 1 })).toBeNull(); + }); +}); From 23b99f003d1ef117ae024f8363add81769b08550 Mon Sep 17 00:00:00 2001 From: Lukas Boehler Date: Sun, 25 Oct 2026 04:53:49 +0100 Subject: [PATCH 2/5] feat(sidekick): Gleap.openSidekick opens an agent as a side panel next to the page The widget frame gets a 'sidekick' app mode: a full-height panel on the right edge (400 px; a full-screen sheet on phones) over a dim layer that blocks the page (overlay, default) or with the page pushed aside by a margin on (push). Closes by its x, Escape, a click on the dimmed page or closeSidekick(); the page comes back exactly (inline styles, scroll, focus, launcher). The page's context follows SPA navigation. A messenger build without the sidekick falls back to startAgent. API: openSidekick, setSidekickContext, closeSidekick, isSidekickOpen, setSidekickProactive; events sidekick-opened/-closed/-proactive. Co-Authored-By: Claude Opus 5.5 --- index.d.ts | 58 ++++ src/Gleap.js | 63 ++++ src/GleapFrameManager.js | 36 ++- src/GleapSidekickManager.js | 614 ++++++++++++++++++++++++++++++++++++ src/UI.js | 125 ++++++++ 5 files changed, 892 insertions(+), 4 deletions(-) create mode 100644 src/GleapSidekickManager.js diff --git a/index.d.ts b/index.d.ts index eccfb39e..90623762 100644 --- a/index.d.ts +++ b/index.d.ts @@ -56,6 +56,44 @@ export namespace Gleap { agentId: string, options?: { context?: any; initialQuestion?: string; initialMessage?: string } ): void; + /** + * What the app tells the sidekick's agent about where the customer is. A plain object without + * `title`, `step` or `data` is taken as `data`. + */ + interface SidekickContext { + /** The page as the customer sees it (default: document.title). */ + title?: string; + /** The onboarding step: its title, or { id, title, index (1-based), total }. */ + step?: string | { id?: string; title?: string; index?: number; total?: number }; + /** What the app knows: plan, seats, a record id, … (plain values or JSON). */ + data?: Record; + } + interface SidekickOptions { + /** A customer agent of the workspace; omitted = Kai Support (the project's AI workflow). */ + agentId?: string; + /** Replaces the context set with setSidekickContext. */ + context?: SidekickContext | Record; + /** Shown first, from the agent (not answered). */ + initialMessage?: string; + /** The customer's first message, answered by the agent. */ + initialQuestion?: string; + /** 'overlay' (default): the page is dimmed and blocked. 'push': it also moves aside (when there is room). */ + mode?: "overlay" | "push"; + /** Start a new conversation even when a sidekick conversation with this agent is still open. */ + newConversation?: boolean; + } + /** + * Opens the sidekick: an agent in a full-height side panel next to the page (a full-screen + * sheet on phones). The agent gets the page's URL (without query and hash), its title and the + * context. Closes with its × button, Escape, a click on the dimmed page or closeSidekick(). + */ + function openSidekick(options?: SidekickOptions): void; + /** Sets (null: clears) the sidekick's context; the next message carries it. */ + function setSidekickContext(context: SidekickContext | Record | null): void; + function closeSidekick(): void; + function isSidekickOpen(): boolean; + /** Allow (default) or stop proactive sidekick opens on this page. */ + function setSidekickProactive(enabled: boolean): void; function isOpened(): boolean; /** * Sets the data region your Gleap project lives in. Call before initialize. @@ -358,6 +396,26 @@ export namespace Gleap { function on(event: "survey-completed", callback: (data: SurveyCompletedEvent) => void): void; /** The survey was closed (by the user or after its ending). */ function on(event: "survey-closed", callback: (data: SurveyClosedEvent) => void): void; + /** The sidekick opened (by openSidekick or a proactive open). */ + function on( + event: "sidekick-opened", + callback: (data: { agentId: string | null; mode: "overlay" | "push" | "sheet"; proactive: boolean }) => void + ): void; + /** The sidekick closed and the page is back. */ + function on( + event: "sidekick-closed", + callback: (data: { reason: "close" | "escape" | "backdrop" | "api" | "replaced" }) => void + ): void; + /** A proactive open arrived: shown, or why not. */ + function on( + event: "sidekick-proactive", + callback: (data: { + id: string; + triggerId: string; + shown: boolean; + reason?: "disabled" | "page_view" | "busy" | "dismissed" | "typing" | "hidden"; + }) => void + ): void; function on( event: | "initialized" diff --git a/src/Gleap.js b/src/Gleap.js index a4f2d3a5..a960555c 100755 --- a/src/Gleap.js +++ b/src/Gleap.js @@ -36,6 +36,7 @@ import GleapProductTours from './GleapProductTours'; import GleapReplayRecorder from './GleapReplayRecorder'; import GleapSession from './GleapSession'; import GleapShortcutListener from './GleapShortcutListener'; +import GleapSidekickManager from './GleapSidekickManager'; import { dispatchSSEEvent, parseSSEStream } from './GleapSSEParser'; import GleapStreamedEvent from './GleapStreamedEvent'; import GleapTabCommunication from './GleapTabCommunication'; @@ -377,6 +378,7 @@ class Gleap { GleapNotificationManager.getInstance().clearAllNotifications(true); GleapTabCommunication.getInstance().stop(); GleapAiChatbarManager.getInstance().destroy(); + GleapSidekickManager.getInstance().destroy(); GleapSession.getInstance().clearSession(0, false); GleapBannerManager.getInstance().removeBannerUI(); @@ -432,6 +434,66 @@ class Gleap { } catch (e) {} } + /** + * Opens the sidekick: an agent in a full-height side panel next to the page, which it dims + * (overlay, the default) or pushes aside (push). The agent gets the page's context: its URL + * (without query and hash), its title, the onboarding step and what the app passes in. + * @param {object} [options] + * - agentId: a customer agent of the workspace; omitted = Kai Support (the project's AI workflow). + * - context: { title?, step?, data? } (replaces the one from setSidekickContext). + * - initialMessage: shown first, from the agent (not answered). + * - initialQuestion: the customer's first message, answered by the agent. + * - mode: 'overlay' (default) or 'push'. + * - newConversation: start fresh even when a sidekick conversation with this agent is open. + */ + static openSidekick(options) { + try { + GleapSidekickManager.getInstance().open(options || {}); + } catch (e) {} + } + + /** + * What the app knows about where the customer is, for the sidekick: { title?, step?, data? } + * (a plain object counts as data). Sent with the next message; null clears it. + * @param {object|null} context + */ + static setSidekickContext(context) { + try { + GleapSidekickManager.getInstance().setContext(context); + } catch (e) {} + } + + /** + * Closes the sidekick and gives the page back as it was. + */ + static closeSidekick() { + try { + GleapSidekickManager.getInstance().close('api'); + } catch (e) {} + } + + /** + * Whether the sidekick is open. + * @returns {boolean} + */ + static isSidekickOpen() { + try { + return GleapSidekickManager.getInstance().isOpen; + } catch (e) { + return false; + } + } + + /** + * Allow (default) or stop proactive sidekick opens on this page (e.g. during checkout). + * @param {boolean} enabled + */ + static setSidekickProactive(enabled) { + try { + GleapSidekickManager.getInstance().setProactiveEnabled(enabled); + } catch (e) {} + } + /** * Set the AI quick actions. * @param {Array} quickActions @@ -1798,6 +1860,7 @@ export { GleapReplayRecorder, GleapSession, GleapShortcutListener, + GleapSidekickManager, GleapStreamedEvent, GleapTabCommunication, GleapTagManager, diff --git a/src/GleapFrameManager.js b/src/GleapFrameManager.js index 66ed50a1..ab358c9d 100644 --- a/src/GleapFrameManager.js +++ b/src/GleapFrameManager.js @@ -19,6 +19,7 @@ import Gleap, { GleapTranslationManager, } from './Gleap'; import GleapAgentToolManager from './GleapAgentToolManager'; +import GleapSidekickManager from './GleapSidekickManager'; import { getCaptureCapabilities } from './GleapCaptureSettings'; import { forwardSurveyAnalyticsEvent } from './GleapSurveyAnalyticsForwarder'; import { bootstrapGleapFrame, loadFromGleapCache, runFunctionWhenDomIsReady, saveToGleapCache } from './GleapHelper'; @@ -157,6 +158,10 @@ export default class GleapFrameManager { } setAppMode(appMode) { + // Leaving the sidekick for another mode (Gleap.open, a survey, …): close it and give the page back first. + if (this.appMode === 'sidekick' && appMode !== 'sidekick') { + GleapSidekickManager.getInstance().onLeaveSidekickMode(); + } this.appMode = appMode; // A new survey starts as Surveys 2.0 until the messenger says otherwise (survey-legacy). this.surveyLegacy = false; @@ -245,8 +250,8 @@ export default class GleapFrameManager { innerContainer.style.height = ''; if (this.appMode === 'survey' || this.isLegacyFullSurvey()) { innerContainer.style.maxHeight = this.frameHeight > 0 ? `${this.frameHeight}px` : ''; - } else if (this.isFullSurvey()) { - // Full screen: the messenger fills the viewport and draws the background itself. + } else if (this.isFullSurvey() || this.appMode === 'sidekick') { + // Full screen / the sidekick's full height: the messenger fills it. innerContainer.style.maxHeight = 'none'; } else { innerContainer.style.maxHeight = `${widgetMaxHeight}px`; @@ -456,7 +461,7 @@ export default class GleapFrameManager { if (flowConfig && flowConfig.hideExpandButton) { return false; } - return !this.isSurvey() && this.hasRoomToExpand(); + return !this.isSurvey() && this.appMode !== 'sidekick' && this.hasRoomToExpand(); } getWidgetExpandedPreference() { @@ -529,6 +534,10 @@ export default class GleapFrameManager { imageViews[imageViews.length - 1].remove(); return; } + if (this.appMode === 'sidekick') { + GleapSidekickManager.getInstance().close('escape'); + return; + } this.hideWidget(); } }; @@ -777,7 +786,9 @@ export default class GleapFrameManager { const modernStyleLeft = 'gleap-frame-container--modern-left'; const noButtonStyleLeft = 'gleap-frame-container--no-button'; const expandedStyle = 'gleap-frame-container--expanded'; + const sidekickStyle = 'gleap-frame-container--sidekick'; const allStyles = [ + sidekickStyle, classicStyle, classicStyleLeft, expandedStyle, @@ -849,6 +860,9 @@ export default class GleapFrameManager { if (this.appMode === 'extended') { this.gleapFrameContainer.classList.add(extendedStyle); } + if (this.appMode === 'sidekick') { + this.gleapFrameContainer.classList.add(sidekickStyle); + } // The end user's choice, independent of the page-driven extended mode. this.appliedWidgetExpanded = this.isWidgetExpanded(); @@ -920,7 +934,8 @@ export default class GleapFrameManager { // Full-screen surveys take the keyboard (digits, letters, Enter) right away. The // messenger can't pull focus into its cross-origin frame without a user gesture // (a survey shown by a trigger has none), so the host page focuses the frame. - if (this.isFullSurvey() && !this.isLegacyFullSurvey()) { + // The sidekick is a dialog over the page: focus moves into it too (and comes back on close). + if ((this.isFullSurvey() && !this.isLegacyFullSurvey()) || this.appMode === 'sidekick') { try { const frame = this.gleapFrameContainer.querySelector('.gleap-frame'); if (frame) frame.focus({ preventScroll: true }); @@ -1016,6 +1031,10 @@ export default class GleapFrameManager { } this.widgetOpened = false; this.updateWidgetStatus(); + // The sidekick gives the page back (dim, push margin, focus) as its panel slides out. + if (this.appMode === 'sidekick') { + GleapSidekickManager.getInstance().onWidgetHidden(); + } GleapFeedbackButtonManager.getInstance().updateFeedbackButtonState(); if (wasVisible) { GleapEventManager.notifyEvent('close'); @@ -1104,11 +1123,16 @@ export default class GleapFrameManager { this.addMessageListener((data) => { if (data.name === 'ping') { this.comReady = true; + // The frame says what it can (`caps`); a sidekick it can't show falls back to the AI bar. + const sidekickFellBack = GleapSidekickManager.getInstance().onFramePing(data.data); this.sendConfigUpdate(); this.sendSessionUpdate(); this.sendCaptureCapabilities(); this.workThroughQueue(); clearTimeout(this.openAfterPingTimeout); + if (sidekickFellBack) { + return; + } this.openAfterPingTimeout = setTimeout(() => { this.openAfterPingTimeout = null; this.runWidgetShouldOpenCallback(); @@ -1219,6 +1243,10 @@ export default class GleapFrameManager { this.hideWidget(); } + if (data.name === 'sidekick-close' || data.name === 'sidekick-engaged') { + GleapSidekickManager.getInstance().onFrameMessage(data); + } + // A verified email moved the conversation to another contact: continue // as that contact (the widget's "Verify email" workflow step). if (data.name === 'adopt-session') { diff --git a/src/GleapSidekickManager.js b/src/GleapSidekickManager.js new file mode 100644 index 00000000..705500ab --- /dev/null +++ b/src/GleapSidekickManager.js @@ -0,0 +1,614 @@ +import { + GleapAiChatbarManager, + GleapEventManager, + GleapFrameManager, + GleapModalManager, + GleapProductTours, + GleapTranslationManager, +} from './Gleap'; +import { loadFromGleapCache, saveToGleapCache } from './GleapHelper'; +import { + SIDEKICK_HIDDEN_MAX_WAIT_MS, + SIDEKICK_TYPING_IDLE_MS, + SIDEKICK_TYPING_MAX_WAIT_MS, + SIDEKICK_WIDTH, + buildSidekickEnvelope, + normalizeSidekickContext, + proactiveRefusalOf, + proactiveSidekickOf, + sidekickModeOf, +} from './GleapSidekickRules'; + +const DISMISSED_CACHE_KEY = 'sidekick-dismissed'; +const BACKDROP_CLASS = 'gleap-sidekick-backdrop'; +const OPEN_CLASS = 'gleap-sidekick-open'; +const PUSH_CLASS = 'gleap-sidekick-push'; +// The panel slides out in 0.24 s; the page and its margin come back with it. +const CLOSE_ANIMATION_MS = 280; +const PUSH_TRANSITION = 'margin 0.32s cubic-bezier(0.16, 1, 0.3, 1)'; + +const isEditable = (element) => { + if (!element || element.nodeType !== 1) { + return false; + } + const tag = element.tagName; + return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || element.isContentEditable === true; +}; + +/** + * The sidekick (decision 13): an agent (Kai Support or one of the workspace's + * customer agents) in a full-height side panel next to the page, with the + * page's context. It is an app mode of the widget frame (`sidekick`): one + * frame, one connection. This manager owns the page side: the backdrop that + * dims and blocks the page, the push margin, restoring the page exactly, the + * context envelope, proactive opens from the socket and their limits. + */ +export default class GleapSidekickManager { + isOpen = false; + mode = null; + // What the open asked for ('push' or not): the layout follows the viewport with it. + requestedMode = null; + // What the app said with setSidekickContext / openSidekick ({ title?, step?, data? }). + appContext = null; + // The open's options (agent, greeting, question) as the frame got them. + openOptions = null; + // Set while a proactive open is on screen: { id, triggerKey, engaged }. + activeProactive = null; + // What the messenger frame announced in its ping: null until it pinged. + frameCaps = null; + proactiveEnabled = true; + // The page view a proactive open already used (the page's href). + proactivePageView = null; + pageView = null; + lastSentEnvelope = null; + lastHostKeyAt = 0; + closeReason = null; + restoreState = null; + // The page styles a closing push still has to put back (its timer runs after the slide). + pendingRestore = null; + backdrop = null; + closeTimer = null; + pendingProactiveTimer = null; + + static instance; + static getInstance() { + if (!this.instance) { + this.instance = new GleapSidekickManager(); + } + return this.instance; + } + + constructor() { + if (typeof window === 'undefined' || typeof document === 'undefined') { + return; + } + try { + this.pageView = window.location.href; + // Typing on the page (never inside the frame: another document) holds proactive opens back. + document.addEventListener( + 'keydown', + (event) => { + if (isEditable(event.target)) { + this.lastHostKeyAt = Date.now(); + } + }, + true + ); + window.addEventListener('resize', () => this.relayout()); + } catch (e) {} + } + + // ── Public API (Gleap.openSidekick & co.) ─────────────────────────────── + + /** + * Opens the sidekick. options: { agentId?, context?, initialMessage?, initialQuestion?, mode?, newConversation? }. + * meta (internal): { proactive?: { id, triggerKey } }. + */ + open(options = {}, meta = {}) { + const opts = options && typeof options === 'object' ? options : {}; + if (Object.prototype.hasOwnProperty.call(opts, 'context')) { + this.appContext = normalizeSidekickContext(opts.context); + } + + // A messenger frame that pinged without the sidekick (an older build somewhere): the AI bar's + // startAgent still gets the customer to the agent. + if (this.frameCaps && this.frameCaps.indexOf('sidekick') === -1) { + this.fallbackToChatbar(opts); + return; + } + + const frameManager = GleapFrameManager.getInstance(); + const agentId = typeof opts.agentId === 'string' && opts.agentId.trim() && opts.agentId !== 'kai' ? opts.agentId.trim() : null; + const wasOpen = this.isOpen; + if (!wasOpen) { + this.captureRestoreState(); + } + clearTimeout(this.closeTimer); + this.closeTimer = null; + this.isOpen = true; + this.closeReason = null; + this.activeProactive = meta && meta.proactive ? { ...meta.proactive, engaged: false } : null; + this.requestedMode = opts.mode === 'push' ? 'push' : 'overlay'; + this.mode = sidekickModeOf({ requested: this.requestedMode, viewportWidth: window.innerWidth }); + this.openOptions = { + agentId, + botId: agentId ? null : this.defaultWorkflowId(), + initialMessage: typeof opts.initialMessage === 'string' ? opts.initialMessage : undefined, + initialQuestion: typeof opts.initialQuestion === 'string' ? opts.initialQuestion : undefined, + newConversation: opts.newConversation === true, + proactiveId: this.activeProactive ? this.activeProactive.id : null, + }; + + this.applyLayout(); + + const envelope = this.currentEnvelope(); + this.lastSentEnvelope = JSON.stringify(envelope); + frameManager.setAppMode('sidekick'); + frameManager.sendMessage({ name: 'sidekick-open', data: { ...this.openOptions, context: envelope } }, true); + frameManager.showWidget(); + + if (!wasOpen) { + GleapEventManager.notifyEvent('sidekick-opened', { + agentId, + mode: this.mode, + proactive: !!this.activeProactive, + }); + } + } + + close(reason = 'api') { + if (!this.isOpen) { + return; + } + this.closeReason = reason; + GleapFrameManager.getInstance().hideWidget(); + } + + setContext(context) { + this.appContext = normalizeSidekickContext(context); + this.sendContextIfChanged(); + } + + setProactiveEnabled(enabled) { + this.proactiveEnabled = enabled !== false; + } + + // ── Hooks from the frame manager ──────────────────────────────────────── + + /** The messenger frame pinged. Returns true when the sidekick fell back and the frame must not open. */ + onFramePing(pingData) { + const caps = pingData && Array.isArray(pingData.caps) ? pingData.caps.filter((cap) => typeof cap === 'string') : []; + this.frameCaps = caps; + if (!this.isOpen || caps.indexOf('sidekick') !== -1) { + return false; + } + // Opened before the frame said what it can, and it can't: undo and use the AI bar. + const frameManager = GleapFrameManager.getInstance(); + frameManager.queue = (frameManager.queue || []).filter((message) => !message || message.name !== 'sidekick-open'); + const opts = { ...this.openOptions }; + this.teardown('replaced'); + frameManager.appMode = 'widget'; + frameManager.updateFrameStyle(); + this.fallbackToChatbar(opts); + return true; + } + + /** The frame manager hid the widget while it was the sidekick. */ + onWidgetHidden() { + if (!this.isOpen) { + return; + } + this.teardown(this.closeReason || 'api'); + clearTimeout(this.closeTimer); + this.closeTimer = setTimeout(() => { + this.closeTimer = null; + const frameManager = GleapFrameManager.getInstance(); + // Back to the widget once the panel slid out, unless something reopened it meanwhile. + if (!this.isOpen && !frameManager.widgetOpened && frameManager.appMode === 'sidekick') { + frameManager.appMode = 'widget'; + frameManager.updateFrameStyle(); + } + }, CLOSE_ANIMATION_MS); + } + + /** Something switches the frame to another mode (Gleap.open, a survey…) while the sidekick is open. */ + onLeaveSidekickMode() { + if (!this.isOpen) { + return; + } + this.closeReason = 'replaced'; + GleapFrameManager.getInstance().hideWidget(); + } + + /** Messages from the messenger frame about the sidekick. */ + onFrameMessage(data) { + if (!data || !data.name) { + return; + } + if (data.name === 'sidekick-close') { + const reason = data.data && data.data.reason === 'escape' ? 'escape' : 'close'; + this.close(reason); + } + if (data.name === 'sidekick-engaged' && this.activeProactive) { + this.activeProactive.engaged = true; + } + } + + /** The page poll (1 s): a new page view, a new URL or title for the open panel. */ + checkUrl(href) { + if (href && href !== this.pageView) { + this.pageView = href; + } + this.sendContextIfChanged(); + } + + // ── Proactive opens (socket `sidekick`) ───────────────────────────────── + + handleProactive(data) { + const proactive = proactiveSidekickOf(data); + if (!proactive) { + return; + } + const startedAt = Date.now(); + const attempt = () => { + this.pendingProactiveTimer = null; + const refusal = proactiveRefusalOf({ + enabled: this.proactiveEnabled, + shownThisPageView: this.proactivePageView !== null && this.proactivePageView === this.pageView, + busy: this.isBusy(), + dismissedAt: this.dismissedAt(proactive.triggerKey), + now: Date.now(), + }); + if (refusal) { + this.reportProactive(proactive, false, refusal); + return; + } + const waited = Date.now() - startedAt; + // Never while they type, and not into a tab nobody looks at: wait, then give up. + if (this.isTyping()) { + if (waited >= SIDEKICK_TYPING_MAX_WAIT_MS) { + this.reportProactive(proactive, false, 'typing'); + return; + } + this.pendingProactiveTimer = setTimeout(attempt, 1000); + return; + } + if (typeof document !== 'undefined' && document.hidden) { + if (waited >= SIDEKICK_HIDDEN_MAX_WAIT_MS) { + this.reportProactive(proactive, false, 'hidden'); + return; + } + this.pendingProactiveTimer = setTimeout(attempt, 1000); + return; + } + this.proactivePageView = this.pageView; + this.open( + { agentId: proactive.agentId || undefined, initialMessage: proactive.message, newConversation: true }, + { proactive: { id: proactive.id, triggerKey: proactive.triggerKey } } + ); + this.reportProactive(proactive, true); + }; + clearTimeout(this.pendingProactiveTimer); + attempt(); + } + + reportProactive(proactive, shown, reason) { + GleapEventManager.notifyEvent('sidekick-proactive', { + id: proactive.id, + triggerId: proactive.triggerKey, + shown, + ...(reason ? { reason } : {}), + }); + } + + isTyping() { + try { + return isEditable(document.activeElement) && Date.now() - this.lastHostKeyAt < SIDEKICK_TYPING_IDLE_MS; + } catch (e) { + return false; + } + } + + // Something else of Gleap's is on screen: the widget (or a survey), the sidekick, a modal, a tour. + isBusy() { + try { + if (this.isOpen || GleapFrameManager.getInstance().isOpened()) { + return true; + } + if (GleapModalManager && GleapModalManager.getInstance().modalContainer) { + return true; + } + if (GleapProductTours && GleapProductTours.getInstance().productTourId) { + return true; + } + } catch (e) {} + return false; + } + + dismissedAt(triggerKey) { + try { + const dismissed = loadFromGleapCache(DISMISSED_CACHE_KEY); + const at = dismissed && dismissed[triggerKey]; + return typeof at === 'number' ? at : null; + } catch (e) { + return null; + } + } + + recordDismissal(triggerKey) { + try { + const now = Date.now(); + const dismissed = loadFromGleapCache(DISMISSED_CACHE_KEY) || {}; + const kept = {}; + // Only the recent ones, at most 50 triggers. + Object.keys(dismissed) + .filter((key) => typeof dismissed[key] === 'number' && now - dismissed[key] < 7 * 24 * 60 * 60 * 1000) + .slice(-49) + .forEach((key) => { + kept[key] = dismissed[key]; + }); + kept[triggerKey] = now; + saveToGleapCache(DISMISSED_CACHE_KEY, kept); + } catch (e) {} + } + + // ── Context ───────────────────────────────────────────────────────────── + + currentEnvelope() { + let href = ''; + let documentTitle = ''; + try { + href = window.location.href; + documentTitle = document.title; + } catch (e) {} + return buildSidekickEnvelope({ href, documentTitle, context: this.appContext }); + } + + sendContextIfChanged() { + if (!this.isOpen) { + return; + } + const envelope = this.currentEnvelope(); + const json = JSON.stringify(envelope); + if (json === this.lastSentEnvelope) { + return; + } + this.lastSentEnvelope = json; + GleapFrameManager.getInstance().sendMessage({ name: 'sidekick-context', data: { context: envelope } }, true); + } + + // The project's AI workflow (the AI bar's) Kai Support starts with when no agent is named. + defaultWorkflowId() { + try { + const chatbar = GleapAiChatbarManager.getInstance(); + return (chatbar && chatbar._getDefaultAgentId && chatbar._getDefaultAgentId()) || null; + } catch (e) { + return null; + } + } + + fallbackToChatbar(opts) { + try { + const context = this.appContext && this.appContext.data ? this.appContext.data : undefined; + GleapAiChatbarManager.getInstance().showWithAgent(opts.agentId || undefined, { + context, + initialMessage: opts.initialMessage || undefined, + initialQuestion: opts.initialQuestion || undefined, + }); + } catch (e) {} + } + + // ── Layout: dim + block the page, push it aside, restore it exactly ───── + + captureRestoreState() { + try { + const html = document.documentElement; + // Reopened while the last push was still sliding back: the page's own values are the ones + // that close saved, not what is on the element now. + const pending = this.pendingRestore; + if (pending) { + clearTimeout(pending.timer); + this.pendingRestore = null; + html.style.transition = pending.state.transition; + } + const own = pending ? pending.state : html.style; + this.restoreState = { + activeElement: document.activeElement, + scrollX: window.scrollX, + scrollY: window.scrollY, + marginLeft: own.marginLeft, + marginRight: own.marginRight, + overflowX: own.overflowX, + transition: own.transition, + pushed: false, + }; + } catch (e) { + this.restoreState = null; + } + } + + isRtl() { + try { + return !!GleapTranslationManager.getInstance().isRTLLayout; + } catch (e) { + return false; + } + } + + applyLayout() { + if (typeof document === 'undefined' || !document.body) { + return; + } + const html = document.documentElement; + html.classList.add(OPEN_CLASS); + + // The dim layer: blocks the page (clicks close the panel, wheel/touch never scroll the page). + if (this.mode !== 'sheet') { + this.showBackdrop(); + } else { + this.removeBackdrop(true); + } + + const state = this.restoreState; + if (this.mode === 'push' && state) { + html.classList.add(PUSH_CLASS); + html.style.transition = PUSH_TRANSITION; + html.style.overflowX = 'clip'; + if (this.isRtl()) { + html.style.marginLeft = `${SIDEKICK_WIDTH}px`; + } else { + html.style.marginRight = `${SIDEKICK_WIDTH}px`; + } + state.pushed = true; + } else if (state && state.pushed) { + this.restorePageStyles(false); + } + + this.updateFrameAccessibility(true); + } + + // The viewport changed while open: phone ↔ desktop, room for the push or not. + relayout() { + if (!this.isOpen) { + return; + } + const next = sidekickModeOf({ requested: this.requestedMode, viewportWidth: window.innerWidth }); + if (next !== this.mode) { + this.mode = next; + this.applyLayout(); + } + } + + showBackdrop() { + if (this.backdrop && document.body.contains(this.backdrop)) { + this.backdrop.classList.remove(`${BACKDROP_CLASS}--closing`); + return; + } + const backdrop = document.createElement('div'); + backdrop.className = BACKDROP_CLASS; + backdrop.setAttribute('aria-hidden', 'true'); + backdrop.addEventListener('click', () => this.close('backdrop')); + const block = (event) => { + if (event.cancelable) { + event.preventDefault(); + } + }; + backdrop.addEventListener('wheel', block, { passive: false }); + backdrop.addEventListener('touchmove', block, { passive: false }); + document.body.appendChild(backdrop); + this.backdrop = backdrop; + } + + removeBackdrop(immediately = false) { + const backdrop = this.backdrop; + this.backdrop = null; + if (!backdrop) { + return; + } + if (immediately) { + backdrop.remove(); + return; + } + backdrop.classList.add(`${BACKDROP_CLASS}--closing`); + setTimeout(() => backdrop.remove(), CLOSE_ANIMATION_MS); + } + + restorePageStyles(restoreScroll) { + const state = this.restoreState; + if (!state || typeof document === 'undefined') { + return; + } + const html = document.documentElement; + html.classList.remove(PUSH_CLASS); + if (state.pushed) { + html.style.marginLeft = state.marginLeft; + html.style.marginRight = state.marginRight; + html.style.overflowX = state.overflowX; + state.pushed = false; + // Keep the transition for the slide back, then the page's own value. + const timer = setTimeout(() => { + this.pendingRestore = null; + html.style.transition = state.transition; + if (restoreScroll) { + try { + window.scrollTo(state.scrollX, state.scrollY); + } catch (e) {} + } + }, CLOSE_ANIMATION_MS + 60); + this.pendingRestore = { state, timer }; + } + } + + updateFrameAccessibility(open) { + try { + const container = GleapFrameManager.getInstance().gleapFrameContainer; + if (!container) { + return; + } + if (open) { + container.setAttribute('role', 'dialog'); + container.setAttribute('aria-modal', this.mode === 'sheet' || this.mode === 'overlay' || this.mode === 'push' ? 'true' : 'false'); + container.setAttribute('aria-label', 'Sidekick'); + } else { + container.removeAttribute('role'); + container.removeAttribute('aria-modal'); + container.removeAttribute('aria-label'); + } + } catch (e) {} + } + + teardown(reason, { silent = false } = {}) { + if (!this.isOpen) { + return; + } + this.isOpen = false; + const state = this.restoreState; + const proactive = this.activeProactive; + this.activeProactive = null; + + this.removeBackdrop(false); + this.restorePageStyles(true); + try { + document.documentElement.classList.remove(OPEN_CLASS); + } catch (e) {} + this.updateFrameAccessibility(false); + + // Focus goes back where it was once the panel is gone (the frame may hold or take it while it + // slides out), unless the person put it somewhere else meanwhile or the panel reopened. + const previous = state && state.activeElement; + if (previous && previous !== document.body) { + setTimeout(() => { + try { + if (this.isOpen || !document.contains(previous) || typeof previous.focus !== 'function') { + return; + } + const active = document.activeElement; + const frame = GleapFrameManager.getInstance().gleapFrame; + if (!active || active === document.body || active === document.documentElement || active === frame) { + previous.focus({ preventScroll: true }); + } + } catch (e) {} + }, CLOSE_ANIMATION_MS + 40); + } + this.restoreState = state && state.pushed ? state : null; + + // A proactive open closed before the customer wrote: dismissed for a day. + if (proactive && !proactive.engaged && reason !== 'replaced') { + this.recordDismissal(proactive.triggerKey); + } + + if (!silent) { + GleapEventManager.notifyEvent('sidekick-closed', { reason }); + } + } + + destroy() { + try { + this.teardown('api', { silent: true }); + this.removeBackdrop(true); + } catch (e) {} + clearTimeout(this.closeTimer); + clearTimeout(this.pendingProactiveTimer); + GleapSidekickManager.instance = null; + } +} diff --git a/src/UI.js b/src/UI.js index 74df2b1e..1321d2b2 100644 --- a/src/UI.js +++ b/src/UI.js @@ -1,4 +1,5 @@ import GleapFeedbackButtonManager from './GleapFeedbackButtonManager'; +import { SIDEKICK_PHONE_MAX_WIDTH, SIDEKICK_WIDTH } from './GleapSidekickRules'; import { applyGleapCSPNonce } from './GleapHelper'; const calculateShadeColor = function (col, amt) { @@ -1596,6 +1597,130 @@ export const injectStyledCSS = ( height: 100% !important; max-height: none !important; } + + /* The sidekick (decision 13): the messenger frame as a full-height panel on the right edge (left + in RTL), over a dim layer that blocks the page (.gleap-sidekick-backdrop). Push mode moves the + page with a margin on (GleapSidekickManager), never a transform. */ + .gleap-frame-container.gleap-frame-container--sidekick, + [dir=rtl].gleap-frame-container.gleap-frame-container--sidekick { + top: 0 !important; + bottom: 0 !important; + right: 0 !important; + left: auto !important; + width: ${SIDEKICK_WIDTH}px !important; + max-width: 100vw !important; + height: 100vh; + height: 100dvh !important; + min-height: 0 !important; + max-height: none !important; + border-radius: 0 !important; + box-shadow: -1px 0 0 rgba(6, 13, 37, 0.06), -18px 0 40px rgba(6, 13, 37, 0.12) !important; + transform-origin: right center; + animation: gleapSidekickIn 0.32s cubic-bezier(0.16, 1, 0.3, 1) both !important; + transition: none !important; + } + + [dir=rtl].gleap-frame-container.gleap-frame-container--sidekick { + right: auto !important; + left: 0 !important; + box-shadow: 1px 0 0 rgba(6, 13, 37, 0.06), 18px 0 40px rgba(6, 13, 37, 0.12) !important; + transform-origin: left center; + animation-name: gleapSidekickInRtl !important; + } + + .gleap-frame-container--sidekick .gleap-frame-container-inner { + width: 100% !important; + height: 100% !important; + max-height: none !important; + } + + .gleap-frame-container--sidekick .gleap-frame-container-inner:before { + top: 0; + height: 100%; + } + + .gleap-frame-container.gleap-frame-container--sidekick.gleap-frame-container--closing { + animation: gleapSidekickOut 0.24s cubic-bezier(0.4, 0, 1, 1) both !important; + } + + [dir=rtl].gleap-frame-container.gleap-frame-container--sidekick.gleap-frame-container--closing { + animation-name: gleapSidekickOutRtl !important; + } + + .gleap-sidekick-backdrop { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: ${zIndexBase + 30}; + background-color: rgba(20, 24, 33, 0.26); + animation: gleapWidgetFade 0.24s ease-out both; + overscroll-behavior: contain; + touch-action: none; + } + + .gleap-sidekick-backdrop--closing { + animation: gleapWidgetFadeOut 0.24s ease-in both; + pointer-events: none; + } + + /* While the sidekick is open the launcher steps aside (the panel has its own close). */ + html.gleap-sidekick-open .bb-feedback-button { + opacity: 0 !important; + visibility: hidden !important; + pointer-events: none !important; + } + + @keyframes gleapSidekickIn { + from { transform: translateX(100%); } + to { transform: translateX(0); } + } + + @keyframes gleapSidekickInRtl { + from { transform: translateX(-100%); } + to { transform: translateX(0); } + } + + @keyframes gleapSidekickOut { + from { transform: translateX(0); } + to { transform: translateX(100%); } + } + + @keyframes gleapSidekickOutRtl { + from { transform: translateX(0); } + to { transform: translateX(-100%); } + } + + /* Phones: a full-screen sheet that slides up (nothing to push, nothing to see behind it). */ + @media only screen and (max-width: ${SIDEKICK_PHONE_MAX_WIDTH}px) { + .gleap-frame-container.gleap-frame-container--sidekick, + [dir=rtl].gleap-frame-container.gleap-frame-container--sidekick { + left: 0 !important; + right: 0 !important; + width: 100vw !important; + box-shadow: none !important; + transform-origin: center bottom; + animation: gleapWidgetSlideUp 0.34s cubic-bezier(0.16, 1, 0.3, 1) both !important; + } + + .gleap-frame-container.gleap-frame-container--sidekick.gleap-frame-container--closing, + [dir=rtl].gleap-frame-container.gleap-frame-container--sidekick.gleap-frame-container--closing { + animation: gleapWidgetSlideDown 0.26s cubic-bezier(0.4, 0, 1, 1) both !important; + } + } + + @media (prefers-reduced-motion: reduce) { + .gleap-frame-container.gleap-frame-container--sidekick, + [dir=rtl].gleap-frame-container.gleap-frame-container--sidekick { + animation: gleapWidgetFade 0.15s ease-out both !important; + } + + .gleap-frame-container.gleap-frame-container--sidekick.gleap-frame-container--closing, + [dir=rtl].gleap-frame-container.gleap-frame-container--sidekick.gleap-frame-container--closing { + animation: gleapWidgetFadeOut 0.15s ease-in both !important; + } + } .bb-feedback-button { margin: 0px; From 90bf8b85b5020b4389a8b025bc6968c1134a059b Mon Sep 17 00:00:00 2001 From: Lukas Boehler Date: Sun, 25 Oct 2026 04:53:49 +0100 Subject: [PATCH 3/5] feat(sidekick): proactive opens from the SDK socket The socket announces the 'sidekick' cap (the server sends proactive opens only to SDKs that do) and hands 'sidekick' messages to the manager, which shows one at most once per page view, never over something else, never while the person types, and not again for a day after a dismissal. The page poll keeps the open panel's context current. Co-Authored-By: Claude Opus 5.5 --- src/GleapStreamedEvent.js | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/GleapStreamedEvent.js b/src/GleapStreamedEvent.js index 79523357..a883e696 100644 --- a/src/GleapStreamedEvent.js +++ b/src/GleapStreamedEvent.js @@ -7,6 +7,7 @@ import Gleap, { GleapAdminManager, GleapEventManager, GleapCaptureManager, + GleapSidekickManager, } from './Gleap'; import { getWebSocketCaps } from './GleapCaptureSettings'; import { gleapDataParser } from './GleapHelper'; @@ -148,10 +149,11 @@ export default class GleapStreamedEvent { return; } - // What this page can capture for capture requests (contract §4, "SDK realtime"). + // What this page can capture for capture requests (contract §4, "SDK realtime"), and that it + // can open the sidekick (the server sends proactive opens only to SDKs that say so). let caps = ''; try { - const capabilities = getWebSocketCaps(); + const capabilities = getWebSocketCaps().concat(['sidekick']); if (capabilities.length > 0) { caps = '&caps=' + capabilities.join(','); } @@ -309,6 +311,11 @@ export default class GleapStreamedEvent { } } + // A proactive sidekick open (a trigger of the workspace): shown at most once per page view. + if (message.name === 'sidekick' && GleapSidekickManager) { + GleapSidekickManager.getInstance().handleProactive(message.data); + } + // A log request from a teammate, AI agent or workflow (capture requests, kind 'logs'). if (message.name === 'capture-request' && GleapCaptureManager) { GleapCaptureManager.getInstance().handleServerRequest(message.data); @@ -422,6 +429,11 @@ export default class GleapStreamedEvent { try { GleapNotificationManager.getInstance().checkPageRulesForUrl(window.location.href); } catch (exp) {} + + // The sidekick follows the page: a new page view, and the open panel's URL and title. + try { + GleapSidekickManager.getInstance().checkUrl(window.location.href); + } catch (exp) {} }, 1000); } From 788ed58cd95332fd4c4825c4b9f0b30313d67203 Mon Sep 17 00:00:00 2001 From: Lukas Boehler Date: Tue, 27 Oct 2026 09:05:36 +0100 Subject: [PATCH 4/5] feat(sidekick): push by default, agent message cards above the launcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - openSidekick pushes the page aside by default (no dim, page usable, aria-modal false, Escape in the page stays the page's). It overlays a dimmed page instead when asked (mode 'overlay'), when the page would get narrower than 720 px, or when a push could break it: a page in a frame, RTL, a margin on , a 100vw layout or a body with its own width, a full-width fixed header/footer. A one-layout probe (margin applied, never painted, put back) decides; sidekick-opened says why (pushFallback). Phones keep the full-screen sheet, now clear of the notch and home indicator. - Gleap.showSidekickMessage({ agentId, message, sender? }) / hideSidekickMessage(): a card in the notification stack (avatar, 3 lines, " · now", its own dismiss button). Clicking it opens the sidekick with the message as the agent's first message. Dismissed = gone for a day in every tab (BroadcastChannel + gleap cache); a route change, the widget opening or another identity take it away. Built with DOM nodes (no markup from the message), a real button, Escape dismisses, a polite live region announces it, reduced motion respected. - Proactive socket messages carry display 'card' | 'open' (none = open, the old protocol); cards wait for nothing, respect setSidekickProactive(false) and disabled in-app notifications. The socket announces the new `sidekick-card` cap. - Opening the sidekick no longer clears the other unread notifications; they hide while it is open and come back. Co-Authored-By: Claude Opus 5.5 --- index.d.ts | 48 +++- src/Gleap.js | 44 +++- src/GleapFrameManager.js | 13 +- src/GleapNotificationManager.js | 186 +++++++++++++-- src/GleapSidekickManager.js | 389 ++++++++++++++++++++++++++++++-- src/GleapSidekickRules.js | 100 +++++++- src/GleapSidekickRules.test.js | 64 +++++- src/GleapStreamedEvent.js | 6 +- src/GleapTabCommunication.js | 6 + src/UI.js | 133 ++++++++++- 10 files changed, 927 insertions(+), 62 deletions(-) diff --git a/index.d.ts b/index.d.ts index 90623762..e579795c 100644 --- a/index.d.ts +++ b/index.d.ts @@ -73,26 +73,48 @@ export namespace Gleap { agentId?: string; /** Replaces the context set with setSidekickContext. */ context?: SidekickContext | Record; - /** Shown first, from the agent (not answered). */ + /** The agent's first message in the conversation (shown, not answered). */ initialMessage?: string; /** The customer's first message, answered by the agent. */ initialQuestion?: string; - /** 'overlay' (default): the page is dimmed and blocked. 'push': it also moves aside (when there is room). */ - mode?: "overlay" | "push"; + /** + * 'push' (default): the page moves aside and stays usable, no dim. It overlays instead when the + * page would get narrower than 720 px or a push could break it (a page in a frame, RTL, a margin + * on , a 100vw layout, a full-width fixed header/footer). 'overlay': over a dimmed page. + * Phones always get a full-screen sheet. + */ + mode?: "push" | "overlay"; /** Start a new conversation even when a sidekick conversation with this agent is still open. */ newConversation?: boolean; } /** * Opens the sidekick: an agent in a full-height side panel next to the page (a full-screen * sheet on phones). The agent gets the page's URL (without query and hash), its title and the - * context. Closes with its × button, Escape, a click on the dimmed page or closeSidekick(). + * context. Closes with its × button, Escape, a click on the dimmed page (overlay) or + * closeSidekick(); the page comes back exactly as it was. */ function openSidekick(options?: SidekickOptions): void; + interface SidekickMessageOptions { + /** A customer agent of the workspace; omitted = Kai Support. */ + agentId?: string; + /** The agent's message (plain text, up to 500 characters). */ + message: string; + /** Shown on the card; default: the project's bot name and avatar. */ + sender?: { name: string; profileImageUrl?: string }; + } + /** + * Shows an agent's message as a card above the launcher. Clicking it opens the sidekick with the + * message as the agent's first message; its × hides it (the same card stays away for a day, in + * every tab). Not shown while the widget or the sidekick is open. Returns whether it shows. + */ + function showSidekickMessage(options: SidekickMessageOptions): boolean; + /** Hides the sidekick message card. */ + function hideSidekickMessage(): void; /** Sets (null: clears) the sidekick's context; the next message carries it. */ function setSidekickContext(context: SidekickContext | Record | null): void; function closeSidekick(): void; function isSidekickOpen(): boolean; - /** Allow (default) or stop proactive sidekick opens on this page. */ + /** Allow (default) or stop proactive sidekick messages (cards and opens) on this page. */ function setSidekickProactive(enabled: boolean): void; function isOpened(): boolean; /** @@ -399,23 +421,35 @@ export namespace Gleap { /** The sidekick opened (by openSidekick or a proactive open). */ function on( event: "sidekick-opened", - callback: (data: { agentId: string | null; mode: "overlay" | "push" | "sheet"; proactive: boolean }) => void + callback: (data: { + agentId: string | null; + mode: "overlay" | "push" | "sheet"; + proactive: boolean; + /** Why a push became an overlay. */ + pushFallback?: "iframe" | "rtl" | "html_margin" | "page_width" | "fixed_bar" | "unknown"; + }) => void ): void; /** The sidekick closed and the page is back. */ function on( event: "sidekick-closed", callback: (data: { reason: "close" | "escape" | "backdrop" | "api" | "replaced" }) => void ): void; - /** A proactive open arrived: shown, or why not. */ + /** A proactive message arrived (a card, or the panel opening): shown, or why not. */ function on( event: "sidekick-proactive", callback: (data: { id: string; triggerId: string; + display: "card" | "open"; shown: boolean; reason?: "disabled" | "page_view" | "busy" | "dismissed" | "typing" | "hidden"; }) => void ): void; + /** The customer dismissed a sidekick message card. */ + function on( + event: "sidekick-message-dismissed", + callback: (data: { agentId: string | null; id?: string; triggerId?: string }) => void + ): void; function on( event: | "initialized" diff --git a/src/Gleap.js b/src/Gleap.js index a960555c..b23bf2ed 100755 --- a/src/Gleap.js +++ b/src/Gleap.js @@ -435,15 +435,19 @@ class Gleap { } /** - * Opens the sidekick: an agent in a full-height side panel next to the page, which it dims - * (overlay, the default) or pushes aside (push). The agent gets the page's context: its URL - * (without query and hash), its title, the onboarding step and what the app passes in. + * Opens the sidekick: an agent in a full-height side panel next to the page. By default it + * pushes the page aside (the page stays visible and usable, no dim); it overlays a dimmed page + * instead when asked for (mode 'overlay'), when the page would get narrower than 720 px, or when + * a push could break the page (a page in a frame, RTL, a margin on , a 100vw layout or a + * full-width fixed header/footer). Phones get a full-screen sheet. Closing puts the page back + * exactly as it was. The agent gets the page's context: its URL (without query and hash), its + * title, the onboarding step and what the app passes in. * @param {object} [options] * - agentId: a customer agent of the workspace; omitted = Kai Support (the project's AI workflow). * - context: { title?, step?, data? } (replaces the one from setSidekickContext). - * - initialMessage: shown first, from the agent (not answered). + * - initialMessage: the agent's first message in the conversation (shown, not answered). * - initialQuestion: the customer's first message, answered by the agent. - * - mode: 'overlay' (default) or 'push'. + * - mode: 'push' (default) or 'overlay'. * - newConversation: start fresh even when a sidekick conversation with this agent is open. */ static openSidekick(options) { @@ -452,6 +456,34 @@ class Gleap { } catch (e) {} } + /** + * Shows an agent's message as a card above the launcher (avatar, the message, " · now"). + * Clicking it opens the sidekick with the message as the agent's first message; its × hides it, + * and the same card stays away for a day in every tab. Not shown while the widget or the sidekick + * is open. + * @param {object} options + * - agentId: a customer agent of the workspace; omitted = Kai Support. + * - message: the agent's message (plain text, up to 500 characters). + * - sender: { name, profileImageUrl? } shown on the card (default: the project's bot name and avatar). + * @returns {boolean} whether the card shows. + */ + static showSidekickMessage(options) { + try { + return GleapSidekickManager.getInstance().showMessage(options || {}); + } catch (e) { + return false; + } + } + + /** + * Hides the sidekick message card (no dismissal is remembered). + */ + static hideSidekickMessage() { + try { + GleapSidekickManager.getInstance().removeCard('api'); + } catch (e) {} + } + /** * What the app knows about where the customer is, for the sidekick: { title?, step?, data? } * (a plain object counts as data). Sent with the next message; null clears it. @@ -485,7 +517,7 @@ class Gleap { } /** - * Allow (default) or stop proactive sidekick opens on this page (e.g. during checkout). + * Allow (default) or stop proactive sidekick messages on this page, cards and opens (e.g. during checkout). * @param {boolean} enabled */ static setSidekickProactive(enabled) { diff --git a/src/GleapFrameManager.js b/src/GleapFrameManager.js index ab358c9d..95e58f86 100644 --- a/src/GleapFrameManager.js +++ b/src/GleapFrameManager.js @@ -535,7 +535,10 @@ export default class GleapFrameManager { return; } if (this.appMode === 'sidekick') { - GleapSidekickManager.getInstance().close('escape'); + // A push panel leaves the page usable: Escape in the page stays the page's. + if (GleapSidekickManager.getInstance().shouldCloseOnPageEscape(evt)) { + GleapSidekickManager.getInstance().close('escape'); + } return; } this.hideWidget(); @@ -944,6 +947,14 @@ export default class GleapFrameManager { } updateUI() { + // The sidekick shows one agent conversation: the other unread messages and their badge stay + // (hidden while the panel is open, see UI.js), as they are for a survey. + if (this.appMode === 'sidekick') { + GleapNotificationManager.getInstance().clearAllNotifications(true); + GleapFeedbackButtonManager.getInstance().updateFeedbackButtonState(); + return; + } + // Clear notifications only when not opening a survey. GleapNotificationManager.getInstance().clearAllNotifications(this.isSurvey()); diff --git a/src/GleapNotificationManager.js b/src/GleapNotificationManager.js index fdfa746e..c68f29a0 100644 --- a/src/GleapNotificationManager.js +++ b/src/GleapNotificationManager.js @@ -7,6 +7,7 @@ import Gleap, { GleapEventManager, GleapAiChatbarManager, GleapTabCommunication, + GleapSidekickManager, } from './Gleap'; import { formatRelativeTime, loadFromGleapCache, saveToGleapCache } from './GleapHelper'; import { checkPageRules } from './GleapPageFilter'; @@ -26,6 +27,10 @@ export default class GleapNotificationManager { showNotificationBadge = true; lastPageRulesUrl = undefined; stackResizeObserver = null; + // A sidekick message card (GleapSidekickManager.showCard): rendered in front of the stack, never + // cached (it belongs to one page view), and not one of `notifications`. + sidekickCard = null; + sidekickLiveRegion = null; // Keep track of the current index of news being shown currentNewsIndex = 0; @@ -80,6 +85,9 @@ export default class GleapNotificationManager { this.updateContainerStyle(); this.reloadNotificationsFromCache(); + if (this.sidekickCard) { + this.renderNotifications(); + } // Start listening for cross-tab notification events (e.g. another tab // clearing already-read notifications). Safe/idempotent; no-ops when @@ -106,10 +114,137 @@ export default class GleapNotificationManager { // Keep the newest MAX_NOTIFICATIONS — the same end shift() trims from. this.notifications = nots.slice(-MAX_NOTIFICATIONS); this.renderNotifications(); + } else if (this.sidekickCard) { + this.renderNotifications(); } } catch (exp) {} } + /** + * Shows (or, with null, removes) the sidekick message card: the agent's avatar, its message + * (three lines at most), " · now", a dismiss button. Clicking it opens the sidekick with + * the message as the agent's first message (GleapSidekickManager.onCardClicked). + */ + setSidekickCard(card) { + const hadCard = !!this.sidekickCard; + this.sidekickCard = card || null; + if (!this.notificationContainer) { + return; + } + this.renderNotifications(); + if (card && !hadCard) { + this.announceSidekickCard(card); + } + } + + // Screen readers hear the new message once (a polite live region that outlives the re-renders). + announceSidekickCard(card) { + try { + if (!this.sidekickLiveRegion || !document.body.contains(this.sidekickLiveRegion)) { + const region = document.createElement('div'); + region.className = 'gleap-sidekick-live'; + region.setAttribute('aria-live', 'polite'); + region.setAttribute('role', 'status'); + document.body.appendChild(region); + this.sidekickLiveRegion = region; + } + const region = this.sidekickLiveRegion; + region.textContent = ''; + setTimeout(() => { + region.textContent = `${card.sender && card.sender.name ? card.sender.name + ': ' : ''}${card.message}`; + }, 100); + } catch (exp) {} + } + + renderSidekickCard(card) { + const sender = card.sender || {}; + const elem = document.createElement('div'); + elem.className = 'gleap-notification-item gleap-notification-card gleap-sidekick-card'; + elem.setAttribute('role', 'group'); + elem.setAttribute('aria-label', sender.name || 'Message'); + + const open = document.createElement('button'); + open.type = 'button'; + open.className = 'gleap-notification-item-container gleap-sidekick-card-open'; + open.onclick = (event) => { + event.stopPropagation(); + try { + GleapSidekickManager.getInstance().onCardClicked(); + } catch (exp) {} + }; + + if (sender.profileImageUrl) { + const avatar = document.createElement('img'); + avatar.className = 'gleap-notification-item-avatar gleap-notification-item-avatar--bot'; + avatar.src = sender.profileImageUrl; + avatar.alt = ''; + open.appendChild(avatar); + } else { + const avatar = document.createElement('span'); + avatar.className = 'gleap-notification-item-avatar gleap-notification-item-avatar--bot gleap-sidekick-card-glyph'; + avatar.setAttribute('aria-hidden', 'true'); + avatar.textContent = (sender.name || 'K').trim().charAt(0).toUpperCase(); + open.appendChild(avatar); + } + + const body = document.createElement('span'); + body.className = 'gleap-notification-item-body'; + const text = document.createElement('span'); + text.className = 'gleap-notification-item-content gleap-sidekick-card-text'; + text.textContent = card.message; + body.appendChild(text); + + const meta = document.createElement('span'); + meta.className = 'gleap-notification-item-meta'; + if (sender.name) { + const name = document.createElement('span'); + name.className = 'gleap-notification-item-sender'; + name.textContent = sender.name; + meta.appendChild(name); + } + const time = formatRelativeTime(card.createdAt, GleapTranslationManager.getInstance().getActiveLanguage()); + if (time) { + if (sender.name) { + const dot = document.createElement('span'); + dot.className = 'gleap-notification-item-meta-dot'; + dot.setAttribute('aria-hidden', 'true'); + dot.textContent = '·'; + meta.appendChild(dot); + } + const timeElem = document.createElement('span'); + timeElem.className = 'gleap-notification-item-time'; + timeElem.textContent = time; + meta.appendChild(timeElem); + } + body.appendChild(meta); + open.appendChild(body); + elem.appendChild(open); + + const dismiss = document.createElement('button'); + dismiss.type = 'button'; + dismiss.className = 'gleap-sidekick-card-dismiss'; + const closeLabel = GleapTranslationManager.translateText('close'); + dismiss.setAttribute('aria-label', closeLabel && closeLabel !== 'close' ? closeLabel : 'Close'); + dismiss.innerHTML = loadIcon('dismiss'); + dismiss.onclick = (event) => { + event.stopPropagation(); + try { + GleapSidekickManager.getInstance().onCardDismissed(); + } catch (exp) {} + }; + elem.appendChild(dismiss); + + elem.addEventListener('keydown', (event) => { + if (event.key === 'Escape') { + event.stopPropagation(); + try { + GleapSidekickManager.getInstance().onCardDismissed(); + } catch (exp) {} + } + }); + return elem; + } + /** * Whether the notification's page rules allow it on the current page. * Notifications keep their outbound pageRules/pageFilter when cached, so @@ -237,21 +372,31 @@ export default class GleapNotificationManager { // Clear the existing notifications in the container (UI only). this.clearAllNotifications(true); - // Append a close button (which clears everything). - const clearElem = document.createElement('div'); - clearElem.onclick = () => { - this.clearAllNotifications(); - // Reset the news index when everything is closed - this.currentNewsIndex = 0; - }; - clearElem.className = 'gleap-notification-close'; - clearElem.innerHTML = loadIcon('dismiss'); - this.notificationContainer.appendChild(clearElem); - // Only render notifications whose page rules allow the current page. // The full list stays in memory + cache, so they re-appear when the // user navigates back to an allowed page (see checkPageRulesForUrl). const visibleNotifications = this.getVisibleNotifications(); + const sidekickCard = this.sidekickCard; + + // Append a close button (which clears everything). A sidekick card on its own has its own + // dismiss button instead. + if (visibleNotifications.length > 0) { + const clearElem = document.createElement('div'); + clearElem.onclick = () => { + // Clearing everything dismisses the sidekick card too. + if (this.sidekickCard) { + try { + GleapSidekickManager.getInstance().onCardDismissed(); + } catch (exp) {} + } + this.clearAllNotifications(); + // Reset the news index when everything is closed + this.currentNewsIndex = 0; + }; + clearElem.className = 'gleap-notification-close'; + clearElem.innerHTML = loadIcon('dismiss'); + this.notificationContainer.appendChild(clearElem); + } // Separate out news notifications vs. others, then sort news by date ascending const newsNotifications = visibleNotifications @@ -427,7 +572,15 @@ export default class GleapNotificationManager { this.notificationContainer.appendChild(elem); } - const hasNotifications = newsNotifications.length > 0 || otherNotifications.length > 0; + // The sidekick card goes in front (the newest). + if (sidekickCard) { + this.notificationContainer.appendChild(this.renderSidekickCard(sidekickCard)); + } + if (this.notificationContainer.classList) { + this.notificationContainer.classList.toggle('gleap-notification-container--sidekick', !!sidekickCard); + } + + const hasNotifications = newsNotifications.length > 0 || otherNotifications.length > 0 || !!sidekickCard; if (!hasNotifications) { // Clear the notification container this.clearAllNotifications(true); @@ -438,7 +591,7 @@ export default class GleapNotificationManager { // container's tap handler, tapping) expands it; any re-render collapses it // again. News pagination already folds all news into one card, so it // counts once. - const cardCount = (newsNotifications.length > 0 ? 1 : 0) + otherNotifications.length; + const cardCount = (newsNotifications.length > 0 ? 1 : 0) + otherNotifications.length + (sidekickCard ? 1 : 0); if (this.notificationContainer.classList) { this.notificationContainer.classList.toggle('gleap-notification-container--stack', cardCount > 1); this.notificationContainer.classList.remove('gleap-notification-container--expanded'); @@ -668,6 +821,13 @@ export default class GleapNotificationManager { // Reset currentNewsIndex whenever we clear them out this.currentNewsIndex = 0; saveToGleapCache(this.unreadNotificationsKey, this.notifications); + // The sidekick card goes with them (the widget opened, another identity…): not a dismissal. + if (this.sidekickCard) { + this.sidekickCard = null; + try { + GleapSidekickManager.getInstance().onNotificationsCleared(); + } catch (exp) {} + } } if (!this.notificationContainer) { diff --git a/src/GleapSidekickManager.js b/src/GleapSidekickManager.js index 705500ab..485f4bec 100644 --- a/src/GleapSidekickManager.js +++ b/src/GleapSidekickManager.js @@ -1,14 +1,20 @@ -import { +import Gleap, { GleapAiChatbarManager, + GleapConfigManager, GleapEventManager, GleapFrameManager, GleapModalManager, + GleapNotificationManager, GleapProductTours, + GleapSession, + GleapTabCommunication, GleapTranslationManager, } from './Gleap'; import { loadFromGleapCache, saveToGleapCache } from './GleapHelper'; import { SIDEKICK_HIDDEN_MAX_WAIT_MS, + SIDEKICK_PHONE_MAX_WIDTH, + SIDEKICK_PUSH_MIN_PAGE_WIDTH, SIDEKICK_TYPING_IDLE_MS, SIDEKICK_TYPING_MAX_WAIT_MS, SIDEKICK_WIDTH, @@ -16,6 +22,9 @@ import { normalizeSidekickContext, proactiveRefusalOf, proactiveSidekickOf, + pushRiskOf, + sidekickCardKeyOf, + sidekickCardOf, sidekickModeOf, } from './GleapSidekickRules'; @@ -27,6 +36,43 @@ const PUSH_CLASS = 'gleap-sidekick-push'; const CLOSE_ANIMATION_MS = 280; const PUSH_TRANSITION = 'margin 0.32s cubic-bezier(0.16, 1, 0.3, 1)'; +// Gleap's own elements never count as page content under the panel. +const isGleapElement = (element) => { + for (let node = element, depth = 0; node && node.nodeType === 1 && depth < 12; node = node.parentElement, depth += 1) { + const className = typeof node.className === 'string' ? node.className : ''; + if (/(^|\s)(gleap-|bb-feedback|bb-capture)/.test(className)) { + return true; + } + } + return false; +}; + +// The element's (or an ancestor's) fixed box, if it is positioned fixed. +const fixedAncestorOf = (element) => { + for (let node = element, depth = 0; node && node.nodeType === 1 && depth < 12; node = node.parentElement, depth += 1) { + if (node === document.body || node === document.documentElement) { + return null; + } + try { + if (window.getComputedStyle(node).position === 'fixed') { + return node; + } + } catch (e) { + return null; + } + } + return null; +}; + +const pagePathOf = (href) => { + try { + const url = new URL(href); + return url.origin + url.pathname; + } catch (e) { + return href || ''; + } +}; + const isEditable = (element) => { if (!element || element.nodeType !== 1) { return false; @@ -69,6 +115,11 @@ export default class GleapSidekickManager { backdrop = null; closeTimer = null; pendingProactiveTimer = null; + // Why the last open did not push (pushRiskOf), for the sidekick-opened event. + pushFallback = null; + relayoutFrame = null; + // The message card above the launcher (showCard): { key, agentId, message, sender, proactive, pagePath }. + card = null; static instance; static getInstance() { @@ -94,7 +145,7 @@ export default class GleapSidekickManager { }, true ); - window.addEventListener('resize', () => this.relayout()); + window.addEventListener('resize', () => this.scheduleRelayout()); } catch (e) {} } @@ -127,9 +178,14 @@ export default class GleapSidekickManager { this.closeTimer = null; this.isOpen = true; this.closeReason = null; - this.activeProactive = meta && meta.proactive ? { ...meta.proactive, engaged: false } : null; - this.requestedMode = opts.mode === 'push' ? 'push' : 'overlay'; - this.mode = sidekickModeOf({ requested: this.requestedMode, viewportWidth: window.innerWidth }); + // Opened from a card the customer clicked: chosen, so closing it is not a dismissal. + this.activeProactive = + meta && meta.proactive ? { ...meta.proactive, engaged: meta.proactive.engaged === true } : null; + // Push is the default; overlay only when asked for (or when the page has no room / could break). + this.requestedMode = opts.mode === 'overlay' ? 'overlay' : 'push'; + this.mode = this.modeForViewport(); + // The panel takes over from any message card. + this.removeCard('opened'); this.openOptions = { agentId, botId: agentId ? null : this.defaultWorkflowId(), @@ -152,6 +208,7 @@ export default class GleapSidekickManager { agentId, mode: this.mode, proactive: !!this.activeProactive, + ...(this.pushFallback ? { pushFallback: this.pushFallback } : {}), }); } } @@ -239,9 +296,33 @@ export default class GleapSidekickManager { if (href && href !== this.pageView) { this.pageView = href; } + // A card belongs to the page it showed on: another route takes it away (not a dismissal). + if (this.card && href && pagePathOf(href) !== this.card.pagePath) { + this.removeCard('navigation'); + } this.sendContextIfChanged(); } + /** Escape on the host page: closes an overlay/sheet; a push panel leaves the usable page its own Escape. */ + shouldCloseOnPageEscape(event) { + if (!this.isOpen) { + return false; + } + if (this.mode !== 'push') { + return true; + } + if (event && event.defaultPrevented) { + return false; + } + try { + const active = document.activeElement; + const frame = GleapFrameManager.getInstance().gleapFrame; + return !active || active === document.body || active === document.documentElement || active === frame; + } catch (e) { + return false; + } + } + // ── Proactive opens (socket `sidekick`) ───────────────────────────────── handleProactive(data) { @@ -250,15 +331,37 @@ export default class GleapSidekickManager { return; } const startedAt = Date.now(); - const attempt = () => { - this.pendingProactiveTimer = null; - const refusal = proactiveRefusalOf({ - enabled: this.proactiveEnabled, + const refusalNow = () => + proactiveRefusalOf({ + // A page that turned off in-app notifications gets no cards either (an open was never one). + enabled: this.proactiveEnabled && !(proactive.display === 'card' && this.inAppNotificationsDisabled()), shownThisPageView: this.proactivePageView !== null && this.proactivePageView === this.pageView, busy: this.isBusy(), dismissedAt: this.dismissedAt(proactive.triggerKey), now: Date.now(), }); + + // A card waits for nothing: it sits above the launcher until it is clicked or dismissed. + if (proactive.display === 'card') { + clearTimeout(this.pendingProactiveTimer); + this.pendingProactiveTimer = null; + const refusal = refusalNow(); + if (refusal) { + this.reportProactive(proactive, false, refusal); + return; + } + this.proactivePageView = this.pageView; + this.showCard( + { agentId: proactive.agentId, message: proactive.message, sender: proactive.sender }, + { proactive: { id: proactive.id, triggerKey: proactive.triggerKey } } + ); + this.reportProactive(proactive, true); + return; + } + + const attempt = () => { + this.pendingProactiveTimer = null; + const refusal = refusalNow(); if (refusal) { this.reportProactive(proactive, false, refusal); return; @@ -296,6 +399,7 @@ export default class GleapSidekickManager { GleapEventManager.notifyEvent('sidekick-proactive', { id: proactive.id, triggerId: proactive.triggerKey, + display: proactive.display, shown, ...(reason ? { reason } : {}), }); @@ -352,6 +456,137 @@ export default class GleapSidekickManager { } catch (e) {} } + // ── Message cards (a proactive message above the launcher) ───────────── + + inAppNotificationsDisabled() { + try { + return !!Gleap.getInstance().disableInAppNotifications; + } catch (e) { + return false; + } + } + + /** The bot persona the widget shows for AI messages (flowConfig operatorName / operatorAvatarImage). */ + defaultSender() { + try { + const flowConfig = GleapConfigManager.getInstance().getFlowConfig() || {}; + const name = typeof flowConfig.operatorName === 'string' && flowConfig.operatorName.trim() ? flowConfig.operatorName.trim() : 'Kai'; + const image = typeof flowConfig.operatorAvatarImage === 'string' ? flowConfig.operatorAvatarImage : ''; + return image && /^https:\/\//i.test(image) ? { name, profileImageUrl: image } : { name }; + } catch (e) { + return { name: 'Kai' }; + } + } + + /** + * Gleap.showSidekickMessage: shows the card unless the panel or the widget is open, or this card + * was dismissed in the last day (any tab). Returns whether it shows. + */ + showMessage(options) { + const card = sidekickCardOf(options); + if (!card) { + return false; + } + const key = sidekickCardKeyOf(card); + if (this.isOpen || this.isWidgetOpen()) { + return false; + } + const dismissedAt = this.dismissedAt(key); + if (proactiveRefusalOf({ enabled: true, shownThisPageView: false, busy: false, dismissedAt, now: Date.now() })) { + return false; + } + this.showCard(card, {}); + return true; + } + + isWidgetOpen() { + try { + return GleapFrameManager.getInstance().isOpened(); + } catch (e) { + return false; + } + } + + showCard(card, meta = {}) { + let href = ''; + try { + href = window.location.href; + } catch (e) {} + const proactive = meta.proactive || null; + this.card = { + key: sidekickCardKeyOf({ triggerKey: proactive && proactive.triggerKey, agentId: card.agentId, message: card.message }), + agentId: card.agentId || null, + message: card.message, + sender: card.sender || this.defaultSender(), + proactive, + pagePath: pagePathOf(href), + createdAt: new Date().toISOString(), + }; + try { + GleapNotificationManager.getInstance().setSidekickCard(this.card); + } catch (e) {} + } + + /** The card was clicked: the panel opens with its message as the agent's first message. */ + onCardClicked() { + const card = this.card; + if (!card) { + return; + } + this.broadcastCardCleared(card.key); + this.open( + { agentId: card.agentId || undefined, initialMessage: card.message, newConversation: true }, + card.proactive ? { proactive: { ...card.proactive, engaged: true } } : {} + ); + } + + /** The card's × (or Escape on it): gone for this page view, and quiet for a day in every tab. */ + onCardDismissed() { + const card = this.card; + if (!card) { + return; + } + this.recordDismissal(card.key); + this.broadcastCardCleared(card.key); + this.removeCard('dismissed'); + GleapEventManager.notifyEvent('sidekick-message-dismissed', { + agentId: card.agentId, + ...(card.proactive ? { id: card.proactive.id, triggerId: card.proactive.triggerKey } : {}), + }); + } + + /** Another tab clicked or dismissed the same card. */ + onCardClearedElsewhere(key) { + if (this.card && key && this.card.key === key) { + this.removeCard('other_tab'); + } + } + + /** The notifications were cleared (the widget opened, the identity changed, …): the card goes with them. */ + onNotificationsCleared() { + this.card = null; + } + + removeCard() { + if (!this.card) { + return; + } + this.card = null; + try { + GleapNotificationManager.getInstance().setSidekickCard(null); + } catch (e) {} + } + + broadcastCardCleared(key) { + try { + GleapTabCommunication.getInstance().sendMessage({ + type: 'sidekick-card-cleared', + key, + gleapId: GleapSession.getInstance().session?.gleapId, + }); + } catch (e) {} + } + // ── Context ───────────────────────────────────────────────────────────── currentEnvelope() { @@ -435,6 +670,107 @@ export default class GleapSidekickManager { } } + /** The mode for the viewport now: a push only where it has room and cannot break the page. */ + modeForViewport() { + const viewportWidth = window.innerWidth; + this.pushFallback = null; + const wantsPush = + this.requestedMode === 'push' && + viewportWidth > SIDEKICK_PHONE_MAX_WIDTH && + viewportWidth - SIDEKICK_WIDTH >= SIDEKICK_PUSH_MIN_PAGE_WIDTH; + const pushRisk = wantsPush ? this.probePush() : null; + if (pushRisk) { + this.pushFallback = pushRisk; + } + return sidekickModeOf({ requested: this.requestedMode, viewportWidth, pushRisk }); + } + + /** + * Whether pushing this page aside could break it (pushRiskOf). Applies the push margin for one + * synchronous layout (no transition, never painted), looks at what still sits where the panel + * goes, and puts the page's own values back. + */ + probePush() { + try { + let inIframe = false; + try { + inIframe = window.self !== window.top; + } catch (e) { + inIframe = true; + } + const html = document.documentElement; + let rtl = this.isRtl(); + try { + rtl = rtl || window.getComputedStyle(html).direction === 'rtl' || (document.body && window.getComputedStyle(document.body).direction === 'rtl'); + } catch (e) {} + if (inIframe || rtl) { + return pushRiskOf({ inIframe, rtl }); + } + + const state = this.restoreState; + const pushed = !!(state && state.pushed); + // The page's own horizontal margin on (ours would replace it). + const ownMarginLeft = pushed ? state.marginLeft : html.style.marginLeft; + const ownMarginRight = pushed ? state.marginRight : html.style.marginRight; + let htmlMargin = !!(ownMarginLeft && parseFloat(ownMarginLeft)) || !!(ownMarginRight && parseFloat(ownMarginRight)); + if (!pushed && !htmlMargin) { + try { + const computed = window.getComputedStyle(html); + htmlMargin = (parseFloat(computed.marginLeft) || 0) !== 0 || (parseFloat(computed.marginRight) || 0) !== 0; + } catch (e) {} + } + if (htmlMargin) { + return pushRiskOf({ htmlMargin }); + } + + const saved = { marginRight: html.style.marginRight, overflowX: html.style.overflowX, transition: html.style.transition }; + if (!pushed) { + html.style.transition = 'none'; + html.style.marginRight = `${SIDEKICK_WIDTH}px`; + html.style.overflowX = 'clip'; + } + const stripHits = []; + try { + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const xs = [viewportWidth - SIDEKICK_WIDTH + 24, viewportWidth - SIDEKICK_WIDTH / 2, viewportWidth - 40]; + const ys = [2, 32, 72, Math.round(viewportHeight / 2), viewportHeight - 72, viewportHeight - 32, viewportHeight - 2]; + const seen = []; + xs.forEach((x) => { + ys.forEach((y) => { + const elements = typeof document.elementsFromPoint === 'function' ? document.elementsFromPoint(x, y) : []; + elements.forEach((element) => { + if (!element || element === html || element === document.body || seen.indexOf(element) !== -1 || isGleapElement(element)) { + return; + } + seen.push(element); + const fixed = fixedAncestorOf(element); + const width = fixed ? fixed.getBoundingClientRect().width : 0; + stripHits.push({ fixed: !!fixed, wide: !!fixed && width >= viewportWidth / 2 }); + }); + }); + }); + // A body with its own width (or 100vw) did not move with the margin. + if (document.body) { + const bodyRect = document.body.getBoundingClientRect(); + if (bodyRect.right > viewportWidth - SIDEKICK_WIDTH + 1 && bodyRect.height > 0) { + stripHits.push({ fixed: false, wide: true }); + } + } + } finally { + if (!pushed) { + html.style.marginRight = saved.marginRight; + html.style.overflowX = saved.overflowX; + html.style.transition = saved.transition; + } + } + return pushRiskOf({ stripHits }); + } catch (e) { + // Measuring failed: the overlay never moves the page. + return 'unknown'; + } + } + applyLayout() { if (typeof document === 'undefined' || !document.body) { return; @@ -442,8 +778,9 @@ export default class GleapSidekickManager { const html = document.documentElement; html.classList.add(OPEN_CLASS); - // The dim layer: blocks the page (clicks close the panel, wheel/touch never scroll the page). - if (this.mode !== 'sheet') { + // The dim layer only over an overlay (blocks the page; a click closes the panel). A push keeps + // the page visible and usable; the phone sheet covers the screen. + if (this.mode === 'overlay') { this.showBackdrop(); } else { this.removeBackdrop(true); @@ -451,14 +788,12 @@ export default class GleapSidekickManager { const state = this.restoreState; if (this.mode === 'push' && state) { + // A margin on , never a transform (fixed headers, 100vw and media queries keep working). + // RTL pages never push (probePush), so the panel is always on the right here. html.classList.add(PUSH_CLASS); html.style.transition = PUSH_TRANSITION; html.style.overflowX = 'clip'; - if (this.isRtl()) { - html.style.marginLeft = `${SIDEKICK_WIDTH}px`; - } else { - html.style.marginRight = `${SIDEKICK_WIDTH}px`; - } + html.style.marginRight = `${SIDEKICK_WIDTH}px`; state.pushed = true; } else if (state && state.pushed) { this.restorePageStyles(false); @@ -467,12 +802,28 @@ export default class GleapSidekickManager { this.updateFrameAccessibility(true); } + scheduleRelayout() { + if (!this.isOpen || this.relayoutFrame !== null) { + return; + } + const run = () => { + this.relayoutFrame = null; + this.relayout(); + }; + try { + this.relayoutFrame = window.requestAnimationFrame(run); + } catch (e) { + this.relayoutFrame = null; + run(); + } + } + // The viewport changed while open: phone ↔ desktop, room for the push or not. relayout() { if (!this.isOpen) { return; } - const next = sidekickModeOf({ requested: this.requestedMode, viewportWidth: window.innerWidth }); + const next = this.modeForViewport(); if (next !== this.mode) { this.mode = next; this.applyLayout(); @@ -547,7 +898,8 @@ export default class GleapSidekickManager { } if (open) { container.setAttribute('role', 'dialog'); - container.setAttribute('aria-modal', this.mode === 'sheet' || this.mode === 'overlay' || this.mode === 'push' ? 'true' : 'false'); + // Overlay and sheet block the page (modal); a push leaves it usable next to the panel. + container.setAttribute('aria-modal', this.mode === 'push' ? 'false' : 'true'); container.setAttribute('aria-label', 'Sidekick'); } else { container.removeAttribute('role'); @@ -609,6 +961,7 @@ export default class GleapSidekickManager { } catch (e) {} clearTimeout(this.closeTimer); clearTimeout(this.pendingProactiveTimer); + this.removeCard('destroy'); GleapSidekickManager.instance = null; } } diff --git a/src/GleapSidekickRules.js b/src/GleapSidekickRules.js index bbe12f47..495d577a 100644 --- a/src/GleapSidekickRules.js +++ b/src/GleapSidekickRules.js @@ -26,6 +26,8 @@ export const SIDEKICK_TYPING_MAX_WAIT_MS = 30000; export const SIDEKICK_HIDDEN_MAX_WAIT_MS = 60000; const STEP_ID = /^[A-Za-z0-9_-]{1,64}$/; +const SENDER_NAME_MAX = 80; +const MESSAGE_MAX = 500; const plain = (value, max) => { if (typeof value !== 'string') { @@ -149,32 +151,116 @@ export const buildSidekickEnvelope = ({ href, documentTitle, context }) => { return envelope; }; -/** overlay (default) · push (only with room for the page) · sheet (phones). */ -export const sidekickModeOf = ({ requested, viewportWidth }) => { +/** + * push (default): the page moves aside and stays usable, no dim · overlay: the panel over a dimmed + * page (asked for, no room for the page next to the panel, or a page push could break) · sheet + * (phones: full screen, never a push). + */ +export const sidekickModeOf = ({ requested, viewportWidth, pushRisk }) => { if (!(viewportWidth > SIDEKICK_PHONE_MAX_WIDTH)) { return 'sheet'; } - if (requested === 'push' && viewportWidth - SIDEKICK_WIDTH >= SIDEKICK_PUSH_MIN_PAGE_WIDTH) { + if (requested !== 'overlay' && !pushRisk && viewportWidth - SIDEKICK_WIDTH >= SIDEKICK_PUSH_MIN_PAGE_WIDTH) { return 'push'; } return 'overlay'; }; -/** A socket `sidekick` message as the SDK uses it, or null. */ +/** + * Why pushing this page aside could break it (the sidekick then overlays instead), or null. + * The probe (GleapSidekickManager.probePush) measures the page with the push margin applied for one + * synchronous layout, never painted: + * - inIframe: the page is itself inside a frame (its "viewport" is someone else's box); + * - rtl: right-to-left pages (their scroll origin and fixed edges flip); + * - htmlMargin: has a horizontal margin of its own (ours would replace it); + * - stripHits: what still sits under the panel with the margin applied. Page content there means a + * 100vw layout or a body with its own width (it did not move); a fixed bar at least half as wide + * as the viewport (header, footer, cookie bar) would lose its right end under the panel. + */ +export const pushRiskOf = ({ inIframe, rtl, htmlMargin, stripHits }) => { + if (inIframe) return 'iframe'; + if (rtl) return 'rtl'; + if (htmlMargin) return 'html_margin'; + const hits = Array.isArray(stripHits) ? stripHits : []; + if (hits.some((hit) => hit && !hit.fixed)) return 'page_width'; + if (hits.some((hit) => hit && hit.fixed && hit.wide)) return 'fixed_bar'; + return null; +}; + + +/** A sender to show on a sidekick card: { name, profileImageUrl? } (https images only), or null. */ +export const sidekickSenderOf = (sender) => { + if (!sender || typeof sender !== 'object') { + return null; + } + const name = plain(sender.name, SENDER_NAME_MAX); + if (!name) { + return null; + } + const image = typeof sender.profileImageUrl === 'string' ? sender.profileImageUrl.trim() : ''; + let profileImageUrl; + try { + if (image && new URL(image).protocol === 'https:') { + profileImageUrl = image; + } + } catch (e) {} + return profileImageUrl ? { name, profileImageUrl } : { name }; +}; + +const AGENT_ID = /^[a-f0-9]{24}$/i; + +/** + * Gleap.showSidekickMessage's options as the card uses them, or null when there is no message: + * { agentId (24-hex or null = Kai Support), message (plain, ≤500), sender (or null: the bot persona) }. + */ +export const sidekickCardOf = (options) => { + if (!options || typeof options !== 'object') { + return null; + } + const message = plain(options.message, MESSAGE_MAX); + if (!message) { + return null; + } + const agentId = typeof options.agentId === 'string' && AGENT_ID.test(options.agentId.trim()) ? options.agentId.trim() : null; + return { agentId, message, sender: sidekickSenderOf(options.sender) }; +}; + +/** + * The key a dismissed card is remembered by (24 h, every tab of this browser): the trigger for a + * server card, the agent and the text for one the app showed by code. + */ +export const sidekickCardKeyOf = ({ triggerKey, agentId, message }) => { + if (typeof triggerKey === 'string' && triggerKey) { + return triggerKey; + } + let hash = 0; + const text = `${agentId || 'kai'}|${message || ''}`; + for (let index = 0; index < text.length; index += 1) { + hash = (hash * 31 + text.charCodeAt(index)) | 0; + } + return `code:${agentId || 'kai'}:${(hash >>> 0).toString(36)}`; +}; + +/** + * A socket `sidekick` message as the SDK uses it, or null. `display` is 'card' (a message card + * above the launcher that opens the panel when clicked) or 'open' (the panel opens at once); + * a message without it is an open (what the server sent before cards existed). + */ export const proactiveSidekickOf = (data) => { if (!data || typeof data !== 'object') { return null; } const id = typeof data.id === 'string' && data.id ? data.id.slice(0, 64) : null; - const message = plain(data.message, 500); + const message = plain(data.message, MESSAGE_MAX); const trigger = data.trigger && typeof data.trigger === 'object' ? data.trigger : null; const triggerKey = trigger && typeof trigger.type === 'string' && typeof trigger.id === 'string' ? `${trigger.type}:${trigger.id}`.slice(0, 160) : null; if (!id || !message || !triggerKey) { return null; } - const agentId = typeof data.agentId === 'string' && /^[a-f0-9]{24}$/i.test(data.agentId) ? data.agentId : null; - return { id, agentId, message, triggerKey }; + const agentId = typeof data.agentId === 'string' && AGENT_ID.test(data.agentId) ? data.agentId : null; + const display = data.display === 'card' ? 'card' : 'open'; + return { id, agentId, message, triggerKey, display, sender: sidekickSenderOf(data.sender) }; }; /** diff --git a/src/GleapSidekickRules.test.js b/src/GleapSidekickRules.test.js index 15c275b0..9b0be74a 100644 --- a/src/GleapSidekickRules.test.js +++ b/src/GleapSidekickRules.test.js @@ -5,6 +5,9 @@ import { normalizeSidekickContext, proactiveRefusalOf, proactiveSidekickOf, + pushRiskOf, + sidekickCardKeyOf, + sidekickCardOf, sidekickModeOf, sidekickRouteOf, } from './GleapSidekickRules'; @@ -63,12 +66,54 @@ describe('the app context', () => { }); describe('layout', () => { - test('overlay by default, push only with room for the page, a sheet on phones', () => { - expect(sidekickModeOf({ requested: undefined, viewportWidth: 1440 })).toBe('overlay'); + test('push by default, overlay when asked or without room for the page, a sheet on phones', () => { + expect(sidekickModeOf({ requested: undefined, viewportWidth: 1440 })).toBe('push'); expect(sidekickModeOf({ requested: 'push', viewportWidth: 1440 })).toBe('push'); - expect(sidekickModeOf({ requested: 'push', viewportWidth: 1000 })).toBe('overlay'); + expect(sidekickModeOf({ requested: 'overlay', viewportWidth: 1440 })).toBe('overlay'); + // 1000 - 400 leaves the page 600 px: under 720, so the panel overlays it. + expect(sidekickModeOf({ requested: undefined, viewportWidth: 1000 })).toBe('overlay'); + expect(sidekickModeOf({ requested: undefined, viewportWidth: 1120 })).toBe('push'); expect(sidekickModeOf({ requested: 'push', viewportWidth: 390 })).toBe('sheet'); - expect(sidekickModeOf({ requested: undefined, viewportWidth: 600 })).toBe('sheet'); + expect(sidekickModeOf({ requested: undefined, viewportWidth: 360 })).toBe('sheet'); + }); + + test('a page a push could break gets the overlay', () => { + expect(sidekickModeOf({ requested: undefined, viewportWidth: 1440, pushRisk: 'fixed_bar' })).toBe('overlay'); + expect(pushRiskOf({ stripHits: [] })).toBeNull(); + expect(pushRiskOf({ inIframe: true })).toBe('iframe'); + expect(pushRiskOf({ rtl: true })).toBe('rtl'); + expect(pushRiskOf({ htmlMargin: true })).toBe('html_margin'); + // Content still under the panel after the push: a 100vw layout or a body with its own width. + expect(pushRiskOf({ stripHits: [{ fixed: false }] })).toBe('page_width'); + // A full-width fixed header would lose its right end; a small fixed corner widget would not. + expect(pushRiskOf({ stripHits: [{ fixed: true, wide: true }] })).toBe('fixed_bar'); + expect(pushRiskOf({ stripHits: [{ fixed: true, wide: false }] })).toBeNull(); + }); +}); + +describe('message cards', () => { + test('plain text, an agent id or Kai Support, https avatars only', () => { + expect(sidekickCardOf({ agentId: '6adca6ce3d7fdf51b1584f08', message: ' Want me\n to do it? ' })).toEqual({ + agentId: '6adca6ce3d7fdf51b1584f08', + message: 'Want me to do it?', + sender: null, + }); + expect(sidekickCardOf({ agentId: '../x', message: 'Hi' }).agentId).toBeNull(); + expect(sidekickCardOf({ message: ' ' })).toBeNull(); + expect(sidekickCardOf({ message: 'x'.repeat(900) }).message.length).toBe(500); + expect(sidekickCardOf({ message: 'Hi', sender: { name: 'Ada', profileImageUrl: 'javascript:alert(1)' } }).sender).toEqual({ name: 'Ada' }); + expect(sidekickCardOf({ message: 'Hi', sender: { name: 'Ada', profileImageUrl: 'https://cdn.example.com/a.png' } }).sender).toEqual({ + name: 'Ada', + profileImageUrl: 'https://cdn.example.com/a.png', + }); + }); + + test('a dismissed card is remembered by its trigger, or by agent + text', () => { + expect(sidekickCardKeyOf({ triggerKey: 'agent_event:a:t1' })).toBe('agent_event:a:t1'); + const key = sidekickCardKeyOf({ agentId: null, message: 'Hi' }); + expect(key).toMatch(/^code:kai:/); + expect(sidekickCardKeyOf({ agentId: null, message: 'Hi' })).toBe(key); + expect(sidekickCardKeyOf({ agentId: null, message: 'Hello' })).not.toBe(key); }); }); @@ -79,7 +124,16 @@ describe('proactive opens', () => { test('a well-formed socket message only', () => { expect( proactiveSidekickOf({ id: 'a1', agentId: '6adca6ce3d7fdf51b1584f08', message: ' Want me to do it? ', trigger: { type: 'agent_event', id: 'x:y' } }) - ).toEqual({ id: 'a1', agentId: '6adca6ce3d7fdf51b1584f08', message: 'Want me to do it?', triggerKey: 'agent_event:x:y' }); + ).toEqual({ + id: 'a1', + agentId: '6adca6ce3d7fdf51b1584f08', + message: 'Want me to do it?', + triggerKey: 'agent_event:x:y', + // Without a display it is an open (the protocol before cards). + display: 'open', + sender: null, + }); + expect(proactiveSidekickOf({ id: 'a1', message: 'Hi', display: 'card', trigger: { type: 'skill', id: 's1' } }).display).toBe('card'); expect(proactiveSidekickOf({ id: 'a1', agentId: null, message: 'Hi', trigger: { type: 'skill', id: 's1' } }).agentId).toBeNull(); expect(proactiveSidekickOf({ id: 'a1', agentId: '../../x', message: 'Hi', trigger: { type: 'skill', id: 's1' } }).agentId).toBeNull(); expect(proactiveSidekickOf({ id: 'a1', message: '', trigger: { type: 'skill', id: 's1' } })).toBeNull(); diff --git a/src/GleapStreamedEvent.js b/src/GleapStreamedEvent.js index a883e696..1e21d1e8 100644 --- a/src/GleapStreamedEvent.js +++ b/src/GleapStreamedEvent.js @@ -153,7 +153,8 @@ export default class GleapStreamedEvent { // can open the sidekick (the server sends proactive opens only to SDKs that say so). let caps = ''; try { - const capabilities = getWebSocketCaps().concat(['sidekick']); + // `sidekick-card`: it can show a proactive message as a card above the launcher. + const capabilities = getWebSocketCaps().concat(['sidekick', 'sidekick-card']); if (capabilities.length > 0) { caps = '&caps=' + capabilities.join(','); } @@ -311,7 +312,8 @@ export default class GleapStreamedEvent { } } - // A proactive sidekick open (a trigger of the workspace): shown at most once per page view. + // A proactive sidekick message (a trigger of the workspace): a card above the launcher or the + // panel itself, at most once per page view. if (message.name === 'sidekick' && GleapSidekickManager) { GleapSidekickManager.getInstance().handleProactive(message.data); } diff --git a/src/GleapTabCommunication.js b/src/GleapTabCommunication.js index 1a27419a..9e371461 100644 --- a/src/GleapTabCommunication.js +++ b/src/GleapTabCommunication.js @@ -3,6 +3,7 @@ import { GleapSession, GleapFeedbackButtonManager, GleapAiChatbarManager, + GleapSidekickManager, } from './Gleap'; /** @@ -107,6 +108,11 @@ export default class GleapTabCommunication { GleapNotificationManager.getInstance().clearNotificationsForConversation(data.shareToken, true); } + if (data.type === 'sidekick-card-cleared' && typeof data.key === 'string') { + // A sibling tab opened or dismissed the same sidekick message card: drop ours too. + GleapSidekickManager.getInstance().onCardClearedElsewhere(data.key); + } + if (data.type === 'chatbar-notification-cleared') { // A sibling tab read/dismissed the AI chatbar pill. Dismiss ours too, // applying with fromOtherTab = true so it isn't re-broadcast (loop guard). diff --git a/src/UI.js b/src/UI.js index 1321d2b2..257cbce5 100644 --- a/src/UI.js +++ b/src/UI.js @@ -1665,13 +1665,130 @@ export const injectStyledCSS = ( pointer-events: none; } - /* While the sidekick is open the launcher steps aside (the panel has its own close). */ - html.gleap-sidekick-open .bb-feedback-button { + /* While the sidekick is open the launcher and the notification cards step aside (the panel has + its own close; the cards come back when it closes). */ + html.gleap-sidekick-open .bb-feedback-button, + html.gleap-sidekick-open .gleap-notification-container { opacity: 0 !important; visibility: hidden !important; pointer-events: none !important; } + /* The sidekick message card: a notification card (same chrome) whose whole body is a button, + three lines of the agent's message, " · now", and its own dismiss button. */ + .gleap-sidekick-card { + position: relative; + } + + .gleap-notification-container .gleap-sidekick-card-open { + -webkit-appearance: none; + appearance: none; + width: 100%; + margin: 0 0 12px 0; + font: inherit; + font-size: 15px; + line-height: 21px; + text-align: start; + letter-spacing: normal; + text-transform: none; + outline: none; + } + + .gleap-notification-container .gleap-sidekick-card-open:focus-visible { + box-shadow: 0 0 0 2px ${backgroundColor}, 0 0 0 4px ${primaryColor}, 0px 10px 30px rgba(0, 0, 0, 0.1); + } + + .gleap-sidekick-card-text { + -webkit-line-clamp: 3 !important; + } + + .gleap-sidekick-card-glyph { + display: flex; + align-items: center; + justify-content: center; + background-color: ${primaryColor}; + color: ${contrastColor}; + font-size: 15px; + font-weight: 600; + line-height: 1; + } + + .gleap-sidekick-card-dismiss { + -webkit-appearance: none; + appearance: none; + position: absolute; + top: -9px; + right: -9px; + width: 26px; + height: 26px; + padding: 0; + margin: 0; + border: none; + border-radius: 100%; + background-color: ${backgroundColor}; + box-shadow: 0px 2px 8px rgba(0, 0, 0, 0.18); + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + opacity: 0; + z-index: 2; + transition: opacity 0.15s ease-out; + } + + [dir=rtl].gleap-notification-container .gleap-sidekick-card-dismiss { + right: auto; + left: -9px; + } + + .gleap-sidekick-card-dismiss svg { + width: 40%; + height: 40%; + object-fit: contain; + fill: ${contrastBackgroundColor}; + } + + .gleap-sidekick-card:hover .gleap-sidekick-card-dismiss, + .gleap-sidekick-card:focus-within .gleap-sidekick-card-dismiss { + opacity: 1; + } + + .gleap-sidekick-card-dismiss:focus-visible { + opacity: 1; + outline: 2px solid ${primaryColor}; + outline-offset: 2px; + } + + /* Touch screens have no hover: the dismiss button is always there. */ + @media (hover: none), only screen and (max-width: 450px) { + .gleap-sidekick-card-dismiss { + opacity: 1; + } + } + + /* Screen readers hear a new card once; nobody sees this. */ + .gleap-sidekick-live { + position: fixed !important; + width: 1px !important; + height: 1px !important; + margin: -1px !important; + padding: 0 !important; + overflow: hidden !important; + clip: rect(0 0 0 0) !important; + white-space: nowrap !important; + border: 0 !important; + } + + @media (prefers-reduced-motion: reduce) { + .gleap-sidekick-card { + animation: none !important; + } + + .gleap-sidekick-card-dismiss { + transition: none; + } + } + @keyframes gleapSidekickIn { from { transform: translateX(100%); } to { transform: translateX(0); } @@ -1692,7 +1809,9 @@ export const injectStyledCSS = ( to { transform: translateX(-100%); } } - /* Phones: a full-screen sheet that slides up (nothing to push, nothing to see behind it). */ + /* Phones: a full-screen sheet that slides up (nothing to push, nothing to see behind it). The + notch and the home indicator stay clear (env() is 0 inside the frame, so the host pads it), + and the sheet is as tall as the visible viewport, like the widget on phones. */ @media only screen and (max-width: ${SIDEKICK_PHONE_MAX_WIDTH}px) { .gleap-frame-container.gleap-frame-container--sidekick, [dir=rtl].gleap-frame-container.gleap-frame-container--sidekick { @@ -1700,10 +1819,18 @@ export const injectStyledCSS = ( right: 0 !important; width: 100vw !important; box-shadow: none !important; + box-sizing: border-box !important; + padding-top: env(safe-area-inset-top, 0px) !important; + padding-bottom: env(safe-area-inset-bottom, 0px) !important; + background-color: ${backgroundColor} !important; transform-origin: center bottom; animation: gleapWidgetSlideUp 0.34s cubic-bezier(0.16, 1, 0.3, 1) both !important; } + .gleap-frame-container--sidekick .gleap-frame-container-inner { + max-height: calc(var(--glvh, 1vh) * 100 - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px)) !important; + } + .gleap-frame-container.gleap-frame-container--sidekick.gleap-frame-container--closing, [dir=rtl].gleap-frame-container.gleap-frame-container--sidekick.gleap-frame-container--closing { animation: gleapWidgetSlideDown 0.26s cubic-bezier(0.4, 0, 1, 1) both !important; From 850e3ec453d7f38d29aea35b79e11574dd1d68c8 Mon Sep 17 00:00:00 2001 From: Lukas Boehler Date: Tue, 27 Oct 2026 09:55:32 +0100 Subject: [PATCH 5/5] feat(sidekick): push works on real pages: fixed headers and footers shrink along MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Every position: fixed element that spans the viewport (left ≈ 0 and right ≈ the edge, or ≥ 90vw wide) gets inline left/width (!important) on the panel's side while pushed, slides with the page, and gets its previous inline values back exactly on close (an element without a style attribute gets none). A throttled MutationObserver adjusts fixed elements added (or turned fixed) while open; resizes re-measure. Gleap's own elements and the backdrop are skipped; transform-centered bars are left alone. - The push probe and its fallbacks (fixed bar, 100vw, html margin, RTL) are gone. Overlay only when asked, when the page would get narrower than 720 px, and by default inside an iframe (mode 'push' forces it there). Phones keep the sheet. A right-to-left widget pushes from the left. 's own margin is kept (ours adds to it). - Pushed, the panel has a hairline edge instead of the overlay shadow. - Sidekick card: in a collapsed notification stack only the stack's × shows. Co-Authored-By: Claude Opus 5.5 --- index.d.ts | 10 +- src/Gleap.js | 14 +- src/GleapSidekickManager.js | 381 ++++++++++++++++++++++++--------- src/GleapSidekickRules.js | 61 ++++-- src/GleapSidekickRules.test.js | 30 ++- src/UI.js | 15 ++ 6 files changed, 361 insertions(+), 150 deletions(-) diff --git a/index.d.ts b/index.d.ts index e579795c..733afe72 100644 --- a/index.d.ts +++ b/index.d.ts @@ -78,10 +78,10 @@ export namespace Gleap { /** The customer's first message, answered by the agent. */ initialQuestion?: string; /** - * 'push' (default): the page moves aside and stays usable, no dim. It overlays instead when the - * page would get narrower than 720 px or a push could break it (a page in a frame, RTL, a margin - * on , a 100vw layout, a full-width fixed header/footer). 'overlay': over a dimmed page. - * Phones always get a full-screen sheet. + * Default: the page moves aside and stays usable, no dim; full-width fixed headers/footers get + * narrower with it and come back exactly. It overlays instead when the page would get narrower + * than 720 px, and by default on a page inside an iframe. 'push': push wherever there is room + * (also inside an iframe). 'overlay': over a dimmed page. Phones always get a full-screen sheet. */ mode?: "push" | "overlay"; /** Start a new conversation even when a sidekick conversation with this agent is still open. */ @@ -426,7 +426,7 @@ export namespace Gleap { mode: "overlay" | "push" | "sheet"; proactive: boolean; /** Why a push became an overlay. */ - pushFallback?: "iframe" | "rtl" | "html_margin" | "page_width" | "fixed_bar" | "unknown"; + pushFallback?: "iframe"; }) => void ): void; /** The sidekick closed and the page is back. */ diff --git a/src/Gleap.js b/src/Gleap.js index b23bf2ed..e0af8adc 100755 --- a/src/Gleap.js +++ b/src/Gleap.js @@ -436,18 +436,18 @@ class Gleap { /** * Opens the sidekick: an agent in a full-height side panel next to the page. By default it - * pushes the page aside (the page stays visible and usable, no dim); it overlays a dimmed page - * instead when asked for (mode 'overlay'), when the page would get narrower than 720 px, or when - * a push could break the page (a page in a frame, RTL, a margin on , a 100vw layout or a - * full-width fixed header/footer). Phones get a full-screen sheet. Closing puts the page back - * exactly as it was. The agent gets the page's context: its URL (without query and hash), its - * title, the onboarding step and what the app passes in. + * pushes the page aside (the page stays visible and usable, no dim; full-width fixed headers and + * footers get narrower with it). It overlays a dimmed page instead when asked for (mode + * 'overlay'), when the page would get narrower than 720 px, and by default inside an iframe + * (mode 'push' forces the push there). Phones get a full-screen sheet. Closing puts the page and + * its fixed bars back exactly as they were. The agent gets the page's context: its URL (without + * query and hash), its title, the onboarding step and what the app passes in. * @param {object} [options] * - agentId: a customer agent of the workspace; omitted = Kai Support (the project's AI workflow). * - context: { title?, step?, data? } (replaces the one from setSidekickContext). * - initialMessage: the agent's first message in the conversation (shown, not answered). * - initialQuestion: the customer's first message, answered by the agent. - * - mode: 'push' (default) or 'overlay'. + * - mode: 'push' (forces the push where there is room) or 'overlay'; default: push unless in an iframe. * - newConversation: start fresh even when a sidekick conversation with this agent is open. */ static openSidekick(options) { diff --git a/src/GleapSidekickManager.js b/src/GleapSidekickManager.js index 485f4bec..b3f77844 100644 --- a/src/GleapSidekickManager.js +++ b/src/GleapSidekickManager.js @@ -13,12 +13,11 @@ import Gleap, { import { loadFromGleapCache, saveToGleapCache } from './GleapHelper'; import { SIDEKICK_HIDDEN_MAX_WAIT_MS, - SIDEKICK_PHONE_MAX_WIDTH, - SIDEKICK_PUSH_MIN_PAGE_WIDTH, SIDEKICK_TYPING_IDLE_MS, SIDEKICK_TYPING_MAX_WAIT_MS, SIDEKICK_WIDTH, buildSidekickEnvelope, + fixedBarAdjustmentOf, normalizeSidekickContext, proactiveRefusalOf, proactiveSidekickOf, @@ -39,6 +38,10 @@ const PUSH_TRANSITION = 'margin 0.32s cubic-bezier(0.16, 1, 0.3, 1)'; // Gleap's own elements never count as page content under the panel. const isGleapElement = (element) => { for (let node = element, depth = 0; node && node.nodeType === 1 && depth < 12; node = node.parentElement, depth += 1) { + // and carry Gleap's state classes (gleap-sidekick-open): never Gleap's own element. + if (node === document.body || node === document.documentElement) { + return false; + } const className = typeof node.className === 'string' ? node.className : ''; if (/(^|\s)(gleap-|bb-feedback|bb-capture)/.test(className)) { return true; @@ -47,22 +50,11 @@ const isGleapElement = (element) => { return false; }; -// The element's (or an ancestor's) fixed box, if it is positioned fixed. -const fixedAncestorOf = (element) => { - for (let node = element, depth = 0; node && node.nodeType === 1 && depth < 12; node = node.parentElement, depth += 1) { - if (node === document.body || node === document.documentElement) { - return null; - } - try { - if (window.getComputedStyle(node).position === 'fixed') { - return node; - } - } catch (e) { - return null; - } - } - return null; -}; +// The inline properties a fixed bar's push adjustment touches (saved and put back exactly). +const FIXED_BAR_PROPS = ['left', 'right', 'width', 'max-width', 'box-sizing', 'transition']; +const FIXED_BAR_TRANSITION = 'left 0.32s cubic-bezier(0.16, 1, 0.3, 1), width 0.32s cubic-bezier(0.16, 1, 0.3, 1)'; +// At most this many elements are looked at per scan (a page's fixed bars sit near the top of the tree). +const FIXED_SCAN_LIMIT = 15000; const pagePathOf = (href) => { try { @@ -117,6 +109,13 @@ export default class GleapSidekickManager { pendingProactiveTimer = null; // Why the last open did not push (pushRiskOf), for the sidekick-opened event. pushFallback = null; + // Fixed bars narrowed for the push: element → { saved inline values }. + fixedBars = new Map(); + fixedBarsRestoreTimer = null; + pendingFixedBarsRestore = null; + fixedObserver = null; + fixedObserverTimer = null; + fixedObserverTargets = []; relayoutFrame = null; // The message card above the launcher (showCard): { key, agentId, message, sender, proactive, pagePath }. card = null; @@ -181,8 +180,8 @@ export default class GleapSidekickManager { // Opened from a card the customer clicked: chosen, so closing it is not a dismissal. this.activeProactive = meta && meta.proactive ? { ...meta.proactive, engaged: meta.proactive.engaged === true } : null; - // Push is the default; overlay only when asked for (or when the page has no room / could break). - this.requestedMode = opts.mode === 'overlay' ? 'overlay' : 'push'; + // Push is the default; 'push' asked for explicitly pushes wherever there is room, 'overlay' never pushes. + this.requestedMode = opts.mode === 'overlay' ? 'overlay' : opts.mode === 'push' ? 'push' : 'auto'; this.mode = this.modeForViewport(); // The panel takes over from any message card. this.removeCard('opened'); @@ -670,105 +669,259 @@ export default class GleapSidekickManager { } } - /** The mode for the viewport now: a push only where it has room and cannot break the page. */ + /** The mode for the viewport now: a push where it has room (720 px for the page), never on phones. */ modeForViewport() { - const viewportWidth = window.innerWidth; - this.pushFallback = null; - const wantsPush = - this.requestedMode === 'push' && - viewportWidth > SIDEKICK_PHONE_MAX_WIDTH && - viewportWidth - SIDEKICK_WIDTH >= SIDEKICK_PUSH_MIN_PAGE_WIDTH; - const pushRisk = wantsPush ? this.probePush() : null; - if (pushRisk) { - this.pushFallback = pushRisk; + let inIframe = false; + try { + inIframe = window.self !== window.top; + } catch (e) { + inIframe = true; } - return sidekickModeOf({ requested: this.requestedMode, viewportWidth, pushRisk }); + const pushRisk = pushRiskOf({ inIframe }); + const mode = sidekickModeOf({ requested: this.requestedMode, viewportWidth: window.innerWidth, pushRisk }); + this.pushFallback = mode === 'overlay' && this.requestedMode === 'auto' && pushRisk ? pushRisk : null; + return mode; } - /** - * Whether pushing this page aside could break it (pushRiskOf). Applies the push margin for one - * synchronous layout (no transition, never painted), looks at what still sits where the panel - * goes, and puts the page's own values back. - */ - probePush() { - try { - let inIframe = false; + // The panel's side: right, or left when the widget is right-to-left (its frame docks left). + panelSide() { + return this.isRtl() ? 'left' : 'right'; + } + + // ── Fixed bars follow the push ────────────────────────────────────────── + // A margin on moves the page, but position: fixed elements are placed against the + // viewport: a full-width header or footer would keep running under the panel. Each one that spans + // the viewport gets narrower on the panel's side (inline left / width, !important), and gets its + // own inline values back exactly when the panel closes. Sticky elements are in the flow and move + // with the margin by themselves. + + /** Narrows the fixed bars under `root` (default: the whole body), skipping Gleap's own elements. */ + adjustFixedBars(root) { + if (this.mode !== 'push' || typeof document === 'undefined' || !document.body) { + return; + } + this.flushFixedBarsRestore(); + const start = root && root.nodeType === 1 ? root : document.body; + const viewportWidth = window.innerWidth; + const side = this.panelSide(); + let seen = 0; + const visit = (element) => { + if (!element || element.nodeType !== 1 || seen > FIXED_SCAN_LIMIT) { + return; + } + seen += 1; + if (isGleapElement(element)) { + return; + } + let style = null; try { - inIframe = window.self !== window.top; + style = window.getComputedStyle(element); } catch (e) { - inIframe = true; + return; } - const html = document.documentElement; - let rtl = this.isRtl(); - try { - rtl = rtl || window.getComputedStyle(html).direction === 'rtl' || (document.body && window.getComputedStyle(document.body).direction === 'rtl'); - } catch (e) {} - if (inIframe || rtl) { - return pushRiskOf({ inIframe, rtl }); - } - - const state = this.restoreState; - const pushed = !!(state && state.pushed); - // The page's own horizontal margin on (ours would replace it). - const ownMarginLeft = pushed ? state.marginLeft : html.style.marginLeft; - const ownMarginRight = pushed ? state.marginRight : html.style.marginRight; - let htmlMargin = !!(ownMarginLeft && parseFloat(ownMarginLeft)) || !!(ownMarginRight && parseFloat(ownMarginRight)); - if (!pushed && !htmlMargin) { + if (style.position === 'fixed') { + if (!this.fixedBars.has(element)) { + this.adjustFixedBar(element, style, viewportWidth, side); + } + // Whatever is inside it moves with it. + return; + } + if (style.display === 'none') { + return; + } + for (let child = element.firstElementChild; child; child = child.nextElementSibling) { + visit(child); + } + }; + if (start === document.body) { + for (let child = start.firstElementChild; child; child = child.nextElementSibling) { + visit(child); + } + } else { + visit(start); + } + } + + adjustFixedBar(element, style, viewportWidth, side) { + try { + // Centered with a transform (left: 50%; translateX(-50%)): moving its left edge would shift it. + if (style.transform && style.transform !== 'none') { + return; + } + const rect = element.getBoundingClientRect(); + const adjustment = fixedBarAdjustmentOf({ rect, viewportWidth, side }); + if (!adjustment) { + return; + } + const saved = { hadStyleAttribute: element.hasAttribute('style') }; + FIXED_BAR_PROPS.forEach((prop) => { + saved[prop] = [element.style.getPropertyValue(prop), element.style.getPropertyPriority(prop)]; + }); + this.fixedBars.set(element, saved); + // It slides along with the page unless it animates its own box already. + const ownTransition = (style.transitionDuration || '').split(',').some((duration) => parseFloat(duration) > 0); + if (!ownTransition) { + element.style.setProperty('transition', FIXED_BAR_TRANSITION, 'important'); + } + element.style.setProperty('box-sizing', 'border-box', 'important'); + element.style.setProperty('max-width', 'none', 'important'); + element.style.setProperty('right', 'auto', 'important'); + element.style.setProperty('left', `${adjustment.left}px`, 'important'); + element.style.setProperty('width', `${adjustment.width}px`, 'important'); + } catch (e) {} + } + + /** Puts one bar's own inline values back (the transition after the slide back, unless `now`). */ + restoreFixedBar(element, saved, now) { + try { + FIXED_BAR_PROPS.forEach((prop) => { + if (prop === 'transition' && !now) { + return; + } + const [value, priority] = saved[prop] || ['', '']; + if (value) { + element.style.setProperty(prop, value, priority); + } else { + element.style.removeProperty(prop); + } + }); + if (now) { + this.dropEmptyStyleAttribute(element, saved); + } + } catch (e) {} + } + + // An element that had no style attribute gets none back (not an empty one). + dropEmptyStyleAttribute(element, saved) { + try { + if (!saved.hadStyleAttribute && element.getAttribute('style') === '') { + element.removeAttribute('style'); + } + } catch (e) {} + } + + restoreFixedBars({ now = false } = {}) { + this.stopFixedObserver(); + const bars = Array.from(this.fixedBars.entries()); + this.fixedBars = new Map(); + bars.forEach(([element, saved]) => this.restoreFixedBar(element, saved, now)); + if (now) { + return; + } + // The transitions stay for the slide back, then the bars' own values. + this.flushFixedBarsRestore(); + this.pendingFixedBarsRestore = () => { + bars.forEach(([element, saved]) => { + if (this.fixedBars.has(element)) { + return; + } + const [value, priority] = saved.transition || ['', '']; try { - const computed = window.getComputedStyle(html); - htmlMargin = (parseFloat(computed.marginLeft) || 0) !== 0 || (parseFloat(computed.marginRight) || 0) !== 0; + if (value) { + element.style.setProperty('transition', value, priority); + } else { + element.style.removeProperty('transition'); + } + this.dropEmptyStyleAttribute(element, saved); } catch (e) {} + }); + }; + this.fixedBarsRestoreTimer = setTimeout(() => this.flushFixedBarsRestore(), CLOSE_ANIMATION_MS + 60); + } + + // Runs a waiting transition restore now (before bars are measured and saved again). + flushFixedBarsRestore() { + clearTimeout(this.fixedBarsRestoreTimer); + this.fixedBarsRestoreTimer = null; + const pending = this.pendingFixedBarsRestore; + this.pendingFixedBarsRestore = null; + if (pending) { + pending(); + } + } + + // New fixed elements while the panel is pushed (a toast, a header that turns fixed on scroll): a + // throttled look at what changed. Bars that are no longer fixed get their values back. + startFixedObserver() { + if (this.fixedObserver || typeof MutationObserver === 'undefined' || !document.body) { + return; + } + this.fixedObserver = new MutationObserver((mutations) => { + for (let index = 0; index < mutations.length; index += 1) { + const mutation = mutations[index]; + if (mutation.type === 'childList') { + mutation.addedNodes.forEach((node) => { + if (node && node.nodeType === 1) this.fixedObserverTargets.push(node); + }); + } else if (mutation.target && mutation.target.nodeType === 1) { + this.fixedObserverTargets.push(mutation.target); + } + } + if (this.fixedObserverTargets.length > 500) { + this.fixedObserverTargets = [document.body]; } - if (htmlMargin) { - return pushRiskOf({ htmlMargin }); + if (!this.fixedObserverTimer) { + this.fixedObserverTimer = setTimeout(() => this.flushFixedObserver(), 250); } + }); + this.fixedObserver.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['class', 'style'] }); + } - const saved = { marginRight: html.style.marginRight, overflowX: html.style.overflowX, transition: html.style.transition }; - if (!pushed) { - html.style.transition = 'none'; - html.style.marginRight = `${SIDEKICK_WIDTH}px`; - html.style.overflowX = 'clip'; + flushFixedObserver() { + this.fixedObserverTimer = null; + const targets = this.fixedObserverTargets; + this.fixedObserverTargets = []; + if (!this.isOpen || this.mode !== 'push') { + return; + } + const viewportWidth = window.innerWidth; + const side = this.panelSide(); + targets.forEach((target) => { + if (!target || !target.isConnected) { + return; } - const stripHits = []; - try { - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; - const xs = [viewportWidth - SIDEKICK_WIDTH + 24, viewportWidth - SIDEKICK_WIDTH / 2, viewportWidth - 40]; - const ys = [2, 32, 72, Math.round(viewportHeight / 2), viewportHeight - 72, viewportHeight - 32, viewportHeight - 2]; - const seen = []; - xs.forEach((x) => { - ys.forEach((y) => { - const elements = typeof document.elementsFromPoint === 'function' ? document.elementsFromPoint(x, y) : []; - elements.forEach((element) => { - if (!element || element === html || element === document.body || seen.indexOf(element) !== -1 || isGleapElement(element)) { - return; - } - seen.push(element); - const fixed = fixedAncestorOf(element); - const width = fixed ? fixed.getBoundingClientRect().width : 0; - stripHits.push({ fixed: !!fixed, wide: !!fixed && width >= viewportWidth / 2 }); - }); - }); - }); - // A body with its own width (or 100vw) did not move with the margin. - if (document.body) { - const bodyRect = document.body.getBoundingClientRect(); - if (bodyRect.right > viewportWidth - SIDEKICK_WIDTH + 1 && bodyRect.height > 0) { - stripHits.push({ fixed: false, wide: true }); + const saved = this.fixedBars.get(target); + if (saved) { + // Our own writes land here too: only a bar that stopped being fixed goes back. + try { + if (window.getComputedStyle(target).position !== 'fixed') { + this.fixedBars.delete(target); + this.restoreFixedBar(target, saved, true); } + } catch (e) {} + return; + } + if (target === document.body) { + this.adjustFixedBars(); + return; + } + // An element inside an adjusted bar moves with it. + for (let node = target.parentElement; node && node !== document.body; node = node.parentElement) { + if (this.fixedBars.has(node)) { + return; } - } finally { - if (!pushed) { - html.style.marginRight = saved.marginRight; - html.style.overflowX = saved.overflowX; - html.style.transition = saved.transition; + } + try { + const style = window.getComputedStyle(target); + if (style.position === 'fixed') { + if (!isGleapElement(target)) this.adjustFixedBar(target, style, viewportWidth, side); + return; } + } catch (e) { + return; } - return pushRiskOf({ stripHits }); - } catch (e) { - // Measuring failed: the overlay never moves the page. - return 'unknown'; + this.adjustFixedBars(target); + }); + } + + stopFixedObserver() { + if (this.fixedObserver) { + this.fixedObserver.disconnect(); + this.fixedObserver = null; } + clearTimeout(this.fixedObserverTimer); + this.fixedObserverTimer = null; + this.fixedObserverTargets = []; } applyLayout() { @@ -788,13 +941,26 @@ export default class GleapSidekickManager { const state = this.restoreState; if (this.mode === 'push' && state) { - // A margin on , never a transform (fixed headers, 100vw and media queries keep working). - // RTL pages never push (probePush), so the panel is always on the right here. + // A margin on on the panel's side, never a transform (media queries, 100vw and the + // page's own layout keep working), plus the page's own margin there. Fixed bars follow. + const side = this.panelSide(); + const property = side === 'left' ? 'marginLeft' : 'marginRight'; + if (!state.pushed) { + let ownMargin = 0; + try { + ownMargin = parseFloat(window.getComputedStyle(html)[property]) || 0; + } catch (e) {} + state.pushSide = side; + state.ownMargin = ownMargin; + } html.classList.add(PUSH_CLASS); html.style.transition = PUSH_TRANSITION; html.style.overflowX = 'clip'; - html.style.marginRight = `${SIDEKICK_WIDTH}px`; + html.style[property] = `${(state.ownMargin || 0) + SIDEKICK_WIDTH}px`; state.pushed = true; + this.restoreFixedBars({ now: true }); + this.adjustFixedBars(); + this.startFixedObserver(); } else if (state && state.pushed) { this.restorePageStyles(false); } @@ -827,6 +993,11 @@ export default class GleapSidekickManager { if (next !== this.mode) { this.mode = next; this.applyLayout(); + } else if (next === 'push') { + // The bars' widths depend on the viewport: measure them again. + this.restoreFixedBars({ now: true }); + this.adjustFixedBars(); + this.startFixedObserver(); } } @@ -871,6 +1042,7 @@ export default class GleapSidekickManager { } const html = document.documentElement; html.classList.remove(PUSH_CLASS); + this.restoreFixedBars(); if (state.pushed) { html.style.marginLeft = state.marginLeft; html.style.marginRight = state.marginRight; @@ -961,6 +1133,7 @@ export default class GleapSidekickManager { } catch (e) {} clearTimeout(this.closeTimer); clearTimeout(this.pendingProactiveTimer); + this.restoreFixedBars({ now: true }); this.removeCard('destroy'); GleapSidekickManager.instance = null; } diff --git a/src/GleapSidekickRules.js b/src/GleapSidekickRules.js index 495d577a..b9abb8c5 100644 --- a/src/GleapSidekickRules.js +++ b/src/GleapSidekickRules.js @@ -153,40 +153,55 @@ export const buildSidekickEnvelope = ({ href, documentTitle, context }) => { /** * push (default): the page moves aside and stays usable, no dim · overlay: the panel over a dimmed - * page (asked for, no room for the page next to the panel, or a page push could break) · sheet - * (phones: full screen, never a push). + * page · sheet (phones: full screen, never a push). + * requested: 'push' (the app asked for it: pushes wherever there is room), 'overlay', or anything + * else (the default: pushes unless pushRisk says the page should not be pushed). */ export const sidekickModeOf = ({ requested, viewportWidth, pushRisk }) => { if (!(viewportWidth > SIDEKICK_PHONE_MAX_WIDTH)) { return 'sheet'; } - if (requested !== 'overlay' && !pushRisk && viewportWidth - SIDEKICK_WIDTH >= SIDEKICK_PUSH_MIN_PAGE_WIDTH) { - return 'push'; + if (requested === 'overlay') { + return 'overlay'; } - return 'overlay'; + if (viewportWidth - SIDEKICK_WIDTH < SIDEKICK_PUSH_MIN_PAGE_WIDTH) { + return 'overlay'; + } + if (requested !== 'push' && pushRisk) { + return 'overlay'; + } + return 'push'; }; /** - * Why pushing this page aside could break it (the sidekick then overlays instead), or null. - * The probe (GleapSidekickManager.probePush) measures the page with the push margin applied for one - * synchronous layout, never painted: - * - inIframe: the page is itself inside a frame (its "viewport" is someone else's box); - * - rtl: right-to-left pages (their scroll origin and fixed edges flip); - * - htmlMargin: has a horizontal margin of its own (ours would replace it); - * - stripHits: what still sits under the panel with the margin applied. Page content there means a - * 100vw layout or a body with its own width (it did not move); a fixed bar at least half as wide - * as the viewport (header, footer, cookie bar) would lose its right end under the panel. + * Why the default should not push this page, or null. Only a page inside a frame: its "viewport" is + * a box of someone else's page, which does not get narrower. (An explicit mode 'push' still pushes.) */ -export const pushRiskOf = ({ inIframe, rtl, htmlMargin, stripHits }) => { - if (inIframe) return 'iframe'; - if (rtl) return 'rtl'; - if (htmlMargin) return 'html_margin'; - const hits = Array.isArray(stripHits) ? stripHits : []; - if (hits.some((hit) => hit && !hit.fixed)) return 'page_width'; - if (hits.some((hit) => hit && hit.fixed && hit.wide)) return 'fixed_bar'; - return null; -}; +export const pushRiskOf = ({ inIframe }) => (inIframe ? 'iframe' : null); +/** + * How a fixed element that spans the viewport (a header, footer, cookie bar, a full-screen layer) + * follows the push, or null to leave it: it keeps its left edge and gets narrower by the panel, on + * the panel's side. `computedWidth` is getComputedStyle(element).width (border-box after the + * adjustment sets box-sizing: border-box, so the rect width is used). + * rect: its box before the push · side: where the panel is ('right' | 'left'). + */ +export const fixedBarAdjustmentOf = ({ rect, viewportWidth, side, panelWidth = SIDEKICK_WIDTH }) => { + if (!rect || !(viewportWidth > 0) || !(rect.width > 0)) { + return null; + } + const spans = (rect.left <= 2 && rect.right >= viewportWidth - 2) || rect.width >= viewportWidth * 0.9; + if (!spans) { + return null; + } + const width = Math.round(rect.width - panelWidth); + if (width <= 0) { + return null; + } + return side === 'left' + ? { left: Math.round(rect.left + panelWidth), width } + : { left: Math.round(rect.left), width }; +}; /** A sender to show on a sidekick card: { name, profileImageUrl? } (https images only), or null. */ export const sidekickSenderOf = (sender) => { diff --git a/src/GleapSidekickRules.test.js b/src/GleapSidekickRules.test.js index 9b0be74a..c850a3c4 100644 --- a/src/GleapSidekickRules.test.js +++ b/src/GleapSidekickRules.test.js @@ -5,6 +5,7 @@ import { normalizeSidekickContext, proactiveRefusalOf, proactiveSidekickOf, + fixedBarAdjustmentOf, pushRiskOf, sidekickCardKeyOf, sidekickCardOf, @@ -70,24 +71,31 @@ describe('layout', () => { expect(sidekickModeOf({ requested: undefined, viewportWidth: 1440 })).toBe('push'); expect(sidekickModeOf({ requested: 'push', viewportWidth: 1440 })).toBe('push'); expect(sidekickModeOf({ requested: 'overlay', viewportWidth: 1440 })).toBe('overlay'); - // 1000 - 400 leaves the page 600 px: under 720, so the panel overlays it. + // 1000 - 400 leaves the page 600 px: under 720, so the panel overlays it (even when push was asked for). expect(sidekickModeOf({ requested: undefined, viewportWidth: 1000 })).toBe('overlay'); + expect(sidekickModeOf({ requested: 'push', viewportWidth: 1000 })).toBe('overlay'); expect(sidekickModeOf({ requested: undefined, viewportWidth: 1120 })).toBe('push'); expect(sidekickModeOf({ requested: 'push', viewportWidth: 390 })).toBe('sheet'); expect(sidekickModeOf({ requested: undefined, viewportWidth: 360 })).toBe('sheet'); }); - test('a page a push could break gets the overlay', () => { - expect(sidekickModeOf({ requested: undefined, viewportWidth: 1440, pushRisk: 'fixed_bar' })).toBe('overlay'); - expect(pushRiskOf({ stripHits: [] })).toBeNull(); + test('a page in a frame overlays by default; an explicit push still pushes', () => { expect(pushRiskOf({ inIframe: true })).toBe('iframe'); - expect(pushRiskOf({ rtl: true })).toBe('rtl'); - expect(pushRiskOf({ htmlMargin: true })).toBe('html_margin'); - // Content still under the panel after the push: a 100vw layout or a body with its own width. - expect(pushRiskOf({ stripHits: [{ fixed: false }] })).toBe('page_width'); - // A full-width fixed header would lose its right end; a small fixed corner widget would not. - expect(pushRiskOf({ stripHits: [{ fixed: true, wide: true }] })).toBe('fixed_bar'); - expect(pushRiskOf({ stripHits: [{ fixed: true, wide: false }] })).toBeNull(); + expect(pushRiskOf({ inIframe: false })).toBeNull(); + expect(sidekickModeOf({ requested: undefined, viewportWidth: 1440, pushRisk: 'iframe' })).toBe('overlay'); + expect(sidekickModeOf({ requested: 'push', viewportWidth: 1440, pushRisk: 'iframe' })).toBe('push'); + }); + + test('fixed bars that span the viewport get narrower on the panel side; others stay', () => { + const header = { left: 0, right: 1440, width: 1440 }; + expect(fixedBarAdjustmentOf({ rect: header, viewportWidth: 1440, side: 'right' })).toEqual({ left: 0, width: 1040 }); + expect(fixedBarAdjustmentOf({ rect: header, viewportWidth: 1440, side: 'left' })).toEqual({ left: 400, width: 1040 }); + // 90 % of the viewport counts too (a bar with a small inset). + expect(fixedBarAdjustmentOf({ rect: { left: 40, right: 1400, width: 1360 }, viewportWidth: 1440, side: 'right' })).toEqual({ left: 40, width: 960 }); + // A corner widget, a sidebar: untouched. + expect(fixedBarAdjustmentOf({ rect: { left: 1100, right: 1420, width: 320 }, viewportWidth: 1440, side: 'right' })).toBeNull(); + expect(fixedBarAdjustmentOf({ rect: { left: 0, right: 260, width: 260 }, viewportWidth: 1440, side: 'right' })).toBeNull(); + expect(fixedBarAdjustmentOf({ rect: { left: 0, right: 0, width: 0 }, viewportWidth: 1440, side: 'right' })).toBeNull(); }); }); diff --git a/src/UI.js b/src/UI.js index 257cbce5..7bd708fb 100644 --- a/src/UI.js +++ b/src/UI.js @@ -1628,6 +1628,15 @@ export const injectStyledCSS = ( animation-name: gleapSidekickInRtl !important; } + /* Pushed: the panel sits next to the page like a column of it, a hairline instead of a throw. */ + html.gleap-sidekick-push .gleap-frame-container.gleap-frame-container--sidekick { + box-shadow: -1px 0 0 ${isDarkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(6, 13, 37, 0.1)'} !important; + } + + html.gleap-sidekick-push [dir=rtl].gleap-frame-container.gleap-frame-container--sidekick { + box-shadow: 1px 0 0 ${isDarkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(6, 13, 37, 0.1)'} !important; + } + .gleap-frame-container--sidekick .gleap-frame-container-inner { width: 100% !important; height: 100% !important; @@ -1766,6 +1775,12 @@ export const injectStyledCSS = ( } } + /* In a collapsed stack the stack's own × (clear all) is the one control; the card's × shows + once the stack is spread out (hover, or the first tap on touch screens). */ + .gleap-notification-container--stack:not(:hover):not(:focus-within):not(.gleap-notification-container--expanded) .gleap-sidekick-card-dismiss { + display: none; + } + /* Screen readers hear a new card once; nobody sees this. */ .gleap-sidekick-live { position: fixed !important;