diff --git a/index.d.ts b/index.d.ts index eccfb39e..733afe72 100644 --- a/index.d.ts +++ b/index.d.ts @@ -56,6 +56,66 @@ 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; + /** The agent's first message in the conversation (shown, not answered). */ + initialMessage?: string; + /** The customer's first message, answered by the agent. */ + initialQuestion?: string; + /** + * 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. */ + 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 (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 messages (cards and 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 +418,38 @@ 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; + /** Why a push became an overlay. */ + pushFallback?: "iframe"; + }) => 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 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 a4f2d3a5..e0af8adc 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,98 @@ class Gleap { } catch (e) {} } + /** + * 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; 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' (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) { + try { + GleapSidekickManager.getInstance().open(options || {}); + } 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. + * @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 messages on this page, cards and opens (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 +1892,7 @@ export { GleapReplayRecorder, GleapSession, GleapShortcutListener, + GleapSidekickManager, GleapStreamedEvent, GleapTabCommunication, GleapTagManager, diff --git a/src/GleapFrameManager.js b/src/GleapFrameManager.js index 66ed50a1..95e58f86 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,13 @@ export default class GleapFrameManager { imageViews[imageViews.length - 1].remove(); return; } + if (this.appMode === 'sidekick') { + // 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(); } }; @@ -777,7 +789,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 +863,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 +937,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 }); @@ -929,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()); @@ -1016,6 +1042,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 +1134,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 +1254,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/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 new file mode 100644 index 00000000..b3f77844 --- /dev/null +++ b/src/GleapSidekickManager.js @@ -0,0 +1,1140 @@ +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_TYPING_IDLE_MS, + SIDEKICK_TYPING_MAX_WAIT_MS, + SIDEKICK_WIDTH, + buildSidekickEnvelope, + fixedBarAdjustmentOf, + normalizeSidekickContext, + proactiveRefusalOf, + proactiveSidekickOf, + pushRiskOf, + sidekickCardKeyOf, + sidekickCardOf, + 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)'; + +// 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; + } + } + return false; +}; + +// 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 { + const url = new URL(href); + return url.origin + url.pathname; + } catch (e) { + return href || ''; + } +}; + +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; + // 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; + + 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.scheduleRelayout()); + } 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; + // 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; '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'); + 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, + ...(this.pushFallback ? { pushFallback: this.pushFallback } : {}), + }); + } + } + + 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; + } + // 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) { + const proactive = proactiveSidekickOf(data); + if (!proactive) { + return; + } + const startedAt = Date.now(); + 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; + } + 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, + display: proactive.display, + 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) {} + } + + // ── 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() { + 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; + } + } + + /** The mode for the viewport now: a push where it has room (720 px for the page), never on phones. */ + modeForViewport() { + let inIframe = false; + try { + inIframe = window.self !== window.top; + } catch (e) { + inIframe = true; + } + 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; + } + + // 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 { + style = window.getComputedStyle(element); + } catch (e) { + return; + } + 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 { + 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 (!this.fixedObserverTimer) { + this.fixedObserverTimer = setTimeout(() => this.flushFixedObserver(), 250); + } + }); + this.fixedObserver.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['class', 'style'] }); + } + + 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 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; + } + } + try { + const style = window.getComputedStyle(target); + if (style.position === 'fixed') { + if (!isGleapElement(target)) this.adjustFixedBar(target, style, viewportWidth, side); + return; + } + } catch (e) { + return; + } + this.adjustFixedBars(target); + }); + } + + stopFixedObserver() { + if (this.fixedObserver) { + this.fixedObserver.disconnect(); + this.fixedObserver = null; + } + clearTimeout(this.fixedObserverTimer); + this.fixedObserverTimer = null; + this.fixedObserverTargets = []; + } + + applyLayout() { + if (typeof document === 'undefined' || !document.body) { + return; + } + const html = document.documentElement; + html.classList.add(OPEN_CLASS); + + // 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); + } + + const state = this.restoreState; + if (this.mode === 'push' && state) { + // 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[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); + } + + 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 = this.modeForViewport(); + 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(); + } + } + + 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); + this.restoreFixedBars(); + 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'); + // 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'); + 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); + this.restoreFixedBars({ now: true }); + this.removeCard('destroy'); + GleapSidekickManager.instance = null; + } +} diff --git a/src/GleapSidekickRules.js b/src/GleapSidekickRules.js new file mode 100644 index 00000000..b9abb8c5 --- /dev/null +++ b/src/GleapSidekickRules.js @@ -0,0 +1,292 @@ +// 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 SENDER_NAME_MAX = 80; +const MESSAGE_MAX = 500; + +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; +}; + +/** + * push (default): the page moves aside and stays usable, no dim · overlay: the panel over a dimmed + * 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') { + return 'overlay'; + } + if (viewportWidth - SIDEKICK_WIDTH < SIDEKICK_PUSH_MIN_PAGE_WIDTH) { + return 'overlay'; + } + if (requested !== 'push' && pushRisk) { + return 'overlay'; + } + return 'push'; +}; + +/** + * 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 }) => (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) => { + 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, 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' && 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) }; +}; + +/** + * 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..c850a3c4 --- /dev/null +++ b/src/GleapSidekickRules.test.js @@ -0,0 +1,162 @@ +import { + SIDEKICK_CONTEXT_LIMITS, + SIDEKICK_DISMISS_MS, + buildSidekickEnvelope, + normalizeSidekickContext, + proactiveRefusalOf, + proactiveSidekickOf, + fixedBarAdjustmentOf, + pushRiskOf, + sidekickCardKeyOf, + sidekickCardOf, + 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('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: 'overlay', viewportWidth: 1440 })).toBe('overlay'); + // 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 in a frame overlays by default; an explicit push still pushes', () => { + expect(pushRiskOf({ inIframe: true })).toBe('iframe'); + 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(); + }); +}); + +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); + }); +}); + +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', + // 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(); + 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(); + }); +}); diff --git a/src/GleapStreamedEvent.js b/src/GleapStreamedEvent.js index 79523357..1e21d1e8 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,12 @@ 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(); + // `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(','); } @@ -309,6 +312,12 @@ export default class GleapStreamedEvent { } } + // 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); + } + // 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 +431,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); } 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 74df2b1e..7bd708fb 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,272 @@ 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; + } + + /* 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; + 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 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; + } + } + + /* 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; + 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); } + } + + @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). 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 { + left: 0 !important; + 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; + } + } + + @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;