From 61a014b7288f98c582e09ba973ca780144e9d0e7 Mon Sep 17 00:00:00 2001 From: conrad mugabe Date: Fri, 5 Jun 2026 06:59:22 +0300 Subject: [PATCH 1/4] fix(mentor): persist custom embed launcher icon across refresh The custom launcher icon configured on the Embed tab lived only in local React state, so it reverted to the default on page refresh. Persist it to the mentor settings: custom mode saves the bubble config as the `embed_icon_selection_data` JSON map (plus the uploaded image as `embed_custom_image`), and default mode clears it with an empty object `{}` (the backend ignores `null`/`''`). Derive custom-vs-default from whether the persisted JSON is a non-empty object via the form's reactive defaultValues, rehydrate the bubble config from settings on load, and make the Icon Selection select controlled so the trigger reflects the hydrated value. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 58 ++-- .../edit-mentor-modal/hooks/useEmbedTab.ts | 252 +++++++++++++----- .../edit-mentor-modal/tabs/embed-tab.tsx | 7 +- components/modals/edit-mentor-modal/utils.ts | 138 ++++++++++ 4 files changed, 364 insertions(+), 91 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2896c002..fc439380 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,43 +4,43 @@ ### Features -* **mentor:** sidebar, agent settings layout changes ([3bc2762](https://github.com/iblai/os/commit/3bc276244face45d4dd5db2668c4a4bbba3a20c6)) - -### Bug Fixes - -* **e2e:** fix for the e2e tests ([3733d96](https://github.com/iblai/os/commit/3733d967e2c3093a4e8df10041cb42ba8dc6fa05)) -* **e2e:** fix the explore page test failiures ([ecf8a27](https://github.com/iblai/os/commit/ecf8a27a7711b08326aac17afe58daebbd39a55c)) -* **e2e:** fixing coverage.json for missing components ([c63ca0e](https://github.com/iblai/os/commit/c63ca0eb7794fbe57b9e28d518e726f045f4757e)) -* **e2e:** fixing for the e2e tests ([d44cc81](https://github.com/iblai/os/commit/d44cc8160fa2e7e86b1884a9e797d4c5d7555dbf)) -* **e2e:** fixing for the e2e tests ([08965fd](https://github.com/iblai/os/commit/08965fd81c7dcd2782e438df218c972f03fa5309)) -* **e2e:** more fixes for the tests ([8a40c32](https://github.com/iblai/os/commit/8a40c321f77c2ba446c92fa5a96459ca1d7f0e0a)) -* **e2e:** more fixes for the tests ([3104d2e](https://github.com/iblai/os/commit/3104d2e352201a8281421b708dc51c703bcce161)) -* **mentor:** adding app-sidebar test ([0e5f654](https://github.com/iblai/os/commit/0e5f65411086e8bc433dc0d04983e6dbb76a72d8)) -* **mentor:** adding app-sidebar test ([deaa14c](https://github.com/iblai/os/commit/deaa14ce048be7433f1e8b302dc3a586e011ffb9)) -* **mentor:** adding data state ([9a1a7fb](https://github.com/iblai/os/commit/9a1a7fb5da3d61c8980e1e884db798ad5a98a5dd)) -* **mentor:** adding unit tests coverage ([740a2d3](https://github.com/iblai/os/commit/740a2d34cb95ea23fc1c7b767596c85f8c96f7ac)) -* **mentor:** coverage fixes ([606508b](https://github.com/iblai/os/commit/606508b445a7eb04c0601d726cae1a87f69327ff)) -* **mentor:** fix for the excel ([d49413a](https://github.com/iblai/os/commit/d49413a0510e86b0d8b2e59549556103869bc765)) -* **mentor:** fix for the packages ([420ae98](https://github.com/iblai/os/commit/420ae981689dd3cbc3ea643ed7a6b2ce682b3ee5)) -* **mentor:** fix for the settings tab unit tests ([c4a6d50](https://github.com/iblai/os/commit/c4a6d50dfdcbfd827b11ff92ec35802e5085340f)) -* **mentor:** fixes for the e2e tests and tabs for agent settings ([276df4e](https://github.com/iblai/os/commit/276df4e304d92dce95dc33a39fa60026edb6347f)) -* **mentor:** fixing for the tabs inconsistant height ([d1f3535](https://github.com/iblai/os/commit/d1f35359596c634e835e010eafa0df5b9305b8db)) -* **mentor:** type check issue fix ([87d9277](https://github.com/iblai/os/commit/87d9277e018b2354604b99b1d3c2dd5e39a24c15)) -* **mentor:** unit test coverage ([d8a0555](https://github.com/iblai/os/commit/d8a05551ad4f4ce29be8943c875eb23f7579e20a)) -* **mentor:** unit test fixes for providers ([44336a5](https://github.com/iblai/os/commit/44336a58ae0a0001f5c3508384edda09fee9c0cc)) +- **mentor:** sidebar, agent settings layout changes ([3bc2762](https://github.com/iblai/os/commit/3bc276244face45d4dd5db2668c4a4bbba3a20c6)) + +### Bug Fixes + +- **e2e:** fix for the e2e tests ([3733d96](https://github.com/iblai/os/commit/3733d967e2c3093a4e8df10041cb42ba8dc6fa05)) +- **e2e:** fix the explore page test failiures ([ecf8a27](https://github.com/iblai/os/commit/ecf8a27a7711b08326aac17afe58daebbd39a55c)) +- **e2e:** fixing coverage.json for missing components ([c63ca0e](https://github.com/iblai/os/commit/c63ca0eb7794fbe57b9e28d518e726f045f4757e)) +- **e2e:** fixing for the e2e tests ([d44cc81](https://github.com/iblai/os/commit/d44cc8160fa2e7e86b1884a9e797d4c5d7555dbf)) +- **e2e:** fixing for the e2e tests ([08965fd](https://github.com/iblai/os/commit/08965fd81c7dcd2782e438df218c972f03fa5309)) +- **e2e:** more fixes for the tests ([8a40c32](https://github.com/iblai/os/commit/8a40c321f77c2ba446c92fa5a96459ca1d7f0e0a)) +- **e2e:** more fixes for the tests ([3104d2e](https://github.com/iblai/os/commit/3104d2e352201a8281421b708dc51c703bcce161)) +- **mentor:** adding app-sidebar test ([0e5f654](https://github.com/iblai/os/commit/0e5f65411086e8bc433dc0d04983e6dbb76a72d8)) +- **mentor:** adding app-sidebar test ([deaa14c](https://github.com/iblai/os/commit/deaa14ce048be7433f1e8b302dc3a586e011ffb9)) +- **mentor:** adding data state ([9a1a7fb](https://github.com/iblai/os/commit/9a1a7fb5da3d61c8980e1e884db798ad5a98a5dd)) +- **mentor:** adding unit tests coverage ([740a2d3](https://github.com/iblai/os/commit/740a2d34cb95ea23fc1c7b767596c85f8c96f7ac)) +- **mentor:** coverage fixes ([606508b](https://github.com/iblai/os/commit/606508b445a7eb04c0601d726cae1a87f69327ff)) +- **mentor:** fix for the excel ([d49413a](https://github.com/iblai/os/commit/d49413a0510e86b0d8b2e59549556103869bc765)) +- **mentor:** fix for the packages ([420ae98](https://github.com/iblai/os/commit/420ae981689dd3cbc3ea643ed7a6b2ce682b3ee5)) +- **mentor:** fix for the settings tab unit tests ([c4a6d50](https://github.com/iblai/os/commit/c4a6d50dfdcbfd827b11ff92ec35802e5085340f)) +- **mentor:** fixes for the e2e tests and tabs for agent settings ([276df4e](https://github.com/iblai/os/commit/276df4e304d92dce95dc33a39fa60026edb6347f)) +- **mentor:** fixing for the tabs inconsistant height ([d1f3535](https://github.com/iblai/os/commit/d1f35359596c634e835e010eafa0df5b9305b8db)) +- **mentor:** type check issue fix ([87d9277](https://github.com/iblai/os/commit/87d9277e018b2354604b99b1d3c2dd5e39a24c15)) +- **mentor:** unit test coverage ([d8a0555](https://github.com/iblai/os/commit/d8a05551ad4f4ce29be8943c875eb23f7579e20a)) +- **mentor:** unit test fixes for providers ([44336a5](https://github.com/iblai/os/commit/44336a58ae0a0001f5c3508384edda09fee9c0cc)) ## [0.71.1](https://github.com/iblai/os/compare/v0.71.0...v0.71.1) (2026-06-03) ### Bug Fixes -* **deps:** bump axios >=1.16.0 and minimatch's brace-expansion to 2.0.3 ([5a9b63f](https://github.com/iblai/os/commit/5a9b63f2f18774e1b47fa57a0f762961bc71976a)) +- **deps:** bump axios >=1.16.0 and minimatch's brace-expansion to 2.0.3 ([5a9b63f](https://github.com/iblai/os/commit/5a9b63f2f18774e1b47fa57a0f762961bc71976a)) ### Chores -* change agentAI -> OS ([ec6cc3c](https://github.com/iblai/os/commit/ec6cc3c0a3a07ec2ce5fad28397c7668c9e79470)) -* change IBL.ai to ibl.ai ([df48cc5](https://github.com/iblai/os/commit/df48cc5f518785d45abb2b5374b91a30dfda4488)) -* update app name in AGENTS.md ([0bc8a6e](https://github.com/iblai/os/commit/0bc8a6e4fd54c0921d20f26ac5f5830256f24c88)) -* update documentation ([d802e80](https://github.com/iblai/os/commit/d802e802227135715d19097a5a08d80771d83614)) +- change agentAI -> OS ([ec6cc3c](https://github.com/iblai/os/commit/ec6cc3c0a3a07ec2ce5fad28397c7668c9e79470)) +- change IBL.ai to ibl.ai ([df48cc5](https://github.com/iblai/os/commit/df48cc5f518785d45abb2b5374b91a30dfda4488)) +- update app name in AGENTS.md ([0bc8a6e](https://github.com/iblai/os/commit/0bc8a6e4fd54c0921d20f26ac5f5830256f24c88)) +- update documentation ([d802e80](https://github.com/iblai/os/commit/d802e802227135715d19097a5a08d80771d83614)) ## [0.71.0](https://github.com/iblai/os/compare/v0.70.1...v0.71.0) (2026-06-02) diff --git a/components/modals/edit-mentor-modal/hooks/useEmbedTab.ts b/components/modals/edit-mentor-modal/hooks/useEmbedTab.ts index f7485d2c..fd649497 100644 --- a/components/modals/edit-mentor-modal/hooks/useEmbedTab.ts +++ b/components/modals/edit-mentor-modal/hooks/useEmbedTab.ts @@ -9,10 +9,17 @@ import { } from '@iblai/iblai-js/data-layer'; import { useForm } from '@tanstack/react-form'; import { useParams } from 'next/navigation'; -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { z } from 'zod'; -import { getEmbedCode } from '../utils'; +import { + buildEmbedIconSelectionData, + buildFloatingBubbleConfigFromSettings, + dataUrlToFile, + getEmbedCode, + hasCustomIconData, + isDataUrl, +} from '../utils'; import { toast } from 'sonner'; import type { ChatMode } from '@iblai/iblai-js/web-utils'; import { useNavigate } from '@/hooks/user-navigate'; @@ -120,30 +127,31 @@ const useEmbedTab = () => { { isLoading: isCreateTokenLoading, data: redirectTokenData }, ] = useCreateRedirectTokenMutation(); const [updateMentorSettings] = useEditMentorMutation(); + const defaultFloatingBubbleConfig: CustomFloatingBubbleConfig = { + image: `${config.dmUrl()}/api/core/orgs/${params.tenantKey}/thumbnail/`, + //use_icon: false, + position: 'bottom-right', + //offsetX: 20, + //offsetY: 20, + size: 'small', + backgroundColor: 'transparent', + textColor: '#ffffff', + subtitleTextColor: '#e5e7eb', + accentColor: '#1d4ed8', + borderRadius: 16, + shadow: false, + title: '', + subtitle: '', + height: 48, + fontSize: 14, + subtitleFontSize: 12, + padding: 12, + imageSize: 32, + strokeColor: '#000', + strokeWidth: 0, + }; const [customFloatingBubbleConfig, setCustomFloatingBubbleConfig] = - useState({ - image: `${config.dmUrl()}/api/core/orgs/${params.tenantKey}/thumbnail/`, - //use_icon: false, - position: 'bottom-right', - //offsetX: 20, - //offsetY: 20, - size: 'small', - backgroundColor: 'transparent', - textColor: '#ffffff', - subtitleTextColor: '#e5e7eb', - accentColor: '#1d4ed8', - borderRadius: 16, - shadow: false, - title: '', - subtitle: '', - height: 48, - fontSize: 14, - subtitleFontSize: 12, - padding: 12, - imageSize: 32, - strokeColor: '#000', - strokeWidth: 0, - }); + useState(defaultFloatingBubbleConfig); const updateConfig = ( key: keyof typeof customFloatingBubbleConfig, @@ -174,6 +182,102 @@ const useEmbedTab = () => { }); }; + // Persist the mentor settings (the multipart PUT). Extracted from + // `syncEmbedSettings` so the Save button can reuse it WITHOUT the + // website-URL validation, redirect-token creation, embed-code generation, or + // embed-code dialog. The payload is identical to what Create Embed sends, so + // the icon persistence (custom JSON + image, and the `{}` clear for default + // mode) behaves the same regardless of which button triggered it. + const saveMentorSettings = async (): Promise<{ success: boolean }> => { + const value = form.state.values; + + // Set is_context_aware for advanced mode + const formValues = { ...value }; + if (formValues.mode === 'advanced') { + formValues.is_context_aware = true; + } + + // Update mentor settings + const valid_values = Object.fromEntries( + Object.entries(formValues).filter( + ([key, value]) => value !== '' || key === 'custom_css', + ), + ); + + // Persist the custom launcher icon config so it survives a page refresh. + // The custom-vs-default mode is derived from whether + // `embed_icon_selection_data` is a NON-EMPTY object (see `hasCustomIconData` + // / `buildFloatingBubbleConfigFromSettings`), so: + // - custom mode: persist the JSON map (bubble config minus the raw image + // binary) plus the image. The request helper JSON-stringifies objects + // automatically, so we pass a plain object. + // - default mode: CLEAR the stored JSON by persisting an empty JSON object + // `{}` so a non-empty map unambiguously means custom and an empty map + // means default on the next reload. + // + // Clearing nuance — verified LIVE against the real API for this multipart + // PUT field. `getFormData` JSON-stringifies a passed object, so the literal + // `{}` below is sent as the string `"{}"`. The three candidate clear values + // behave as: + // - `''` (empty string) -> HTTP 400 ("Value must be valid JSON"). + // - `'null'` (JSON null, the old `JSON.stringify(null)` approach) -> HTTP + // 200 but the field is NOT changed: DRF's partial-update silently + // ignores JSON null, so the old custom JSON persists. THIS WAS THE BUG. + // - `'{}'` (empty JSON object) -> HTTP 200 and the field IS set to `{}`. + // Only `{}` actually clears, so default mode sends an empty object literal. + // On hydrate, `{}` has no own keys, so `hasCustomIconData` is false and the + // mode correctly derives back to 'default'. + const isCustomIcon = formValues.icon_selection === 'custom'; + const embed_icon_selection_data: Record = isCustomIcon + ? buildEmbedIconSelectionData(customFloatingBubbleConfig) + : {}; + + // - `embed_custom_image`: when the user just uploaded an image it lives in + // state as a base64 data URL — convert it to a real File so it is sent as + // multipart binary. When it is already a resolved URL (unchanged image) + // we omit it so the previously-saved image isn't clobbered. In default + // mode we never send an image. + const imageValue = customFloatingBubbleConfig.image; + let embed_custom_image: File | undefined; + if (isCustomIcon && isDataUrl(imageValue)) { + embed_custom_image = dataUrlToFile(imageValue) ?? undefined; + } + + const response = await updateMentorSettings({ + mentor: mentorId, + org: params.tenantKey, + // @ts-expect-error - userId is required by the API but not reflected in the type definition + userId: getUserName(), + // `embed_custom_image` is typed as `string` in the SDK request model, but + // the endpoint is multipart and the request helper appends a File/Blob as + // binary, so we send a File for new uploads via a narrow cast. + formData: { + ...valid_values, + metadata: { safety_disclaimer: valid_values.safety_disclaimer }, + embed_icon_selection_data, + // Only include the image key when we have a new File to upload so an + // unchanged (already-persisted) image is left untouched by the backend. + ...(embed_custom_image + ? { embed_custom_image: embed_custom_image as unknown as string } + : {}), + }, + }); + if (response?.error) { + console.error( + `Failed to update mentor settings for mentor (${mentorId}) in org (${params.tenantKey})`, + response.error, + ); + const errorMessage = + (response.error as any)?.error?.error ?? + 'An Unknown error occurred. Please try again'; + setCreateTokenError(errorMessage); + toast.error(errorMessage); + return { success: false }; + } + + return { success: true }; + }; + const syncEmbedSettings = async (): Promise<{ success: boolean; redirectToken?: string; @@ -190,21 +294,15 @@ const useEmbedTab = () => { return { success: false }; } - // Set is_context_aware for advanced mode - const formValues = { ...value }; - if (formValues.mode === 'advanced') { - formValues.is_context_aware = true; - } - let redirectTokenResponse: { data?: { token?: string } } | null = null; // Create redirect token if not anonymous - if (!formValues.allow_anonymous) { + if (!value.allow_anonymous) { try { const response = await createRedirectToken({ org: params.tenantKey, requestBody: { - url: formValues.website_url, + url: value.website_url, mentor_unique_id: mentorPublicSettings?.mentor_unique_id, }, }); @@ -217,43 +315,19 @@ const useEmbedTab = () => { redirectTokenResponse = response; } catch (error) { console.error( - `Failed to create redirect token for website (${formValues.website_url}) in org (${params.tenantKey})`, + `Failed to create redirect token for website (${value.website_url}) in org (${params.tenantKey})`, error, ); setCreateTokenError( - `Failed to create redirect token for website (${formValues.website_url}) in org (${params.tenantKey})`, + `Failed to create redirect token for website (${value.website_url}) in org (${params.tenantKey})`, ); console.error(JSON.stringify({ tenant: tenantKey, error })); return { success: false }; } } - // Update mentor settings - const valid_values = Object.fromEntries( - Object.entries(formValues).filter( - ([key, value]) => value !== '' || key === 'custom_css', - ), - ); - const response = await updateMentorSettings({ - mentor: mentorId, - org: params.tenantKey, - // @ts-expect-error - userId is required by the API but not reflected in the type definition - userId: getUserName(), - formData: { - ...valid_values, - metadata: { safety_disclaimer: valid_values.safety_disclaimer }, - }, - }); - if (response?.error) { - console.error( - `Failed to update mentor settings for mentor (${mentorId}) in org (${params.tenantKey})`, - response.error, - ); - const errorMessage = - (response.error as any)?.error?.error ?? - 'An Unknown error occurred. Please try again'; - setCreateTokenError(errorMessage); - toast.error(errorMessage); + const saveResult = await saveMentorSettings(); + if (!saveResult.success) { return { success: false }; } @@ -279,6 +353,24 @@ const useEmbedTab = () => { show_catalogue: (mentorPublicSettings as { show_catalogue?: boolean } | undefined) ?.show_catalogue ?? true, + // The custom-vs-default launcher icon mode is derived from whether the + // persisted `embed_icon_selection_data` is a NON-EMPTY object (mirroring + // `buildFloatingBubbleConfigFromSettings` via the shared + // `hasCustomIconData` predicate, so both call sites stay in lockstep). A + // cleared field reads back as the empty object `{}`, which must yield + // 'default'. Initialize straight from settings so TanStack Form's reactive + // defaultValues hydrate it the same way the sibling fields above are + // hydrated — instead of an imperative `setFieldValue` in an effect that + // the reactive re-init would clobber. + icon_selection: hasCustomIconData( + ( + mentorPublicSettings as + | { embed_icon_selection_data?: unknown } + | undefined + )?.embed_icon_selection_data, + ) + ? 'custom' + : 'default', starter_prompts: mentorPublicSettings?.starter_prompts === 'suggested_prompt' ? 'suggested_prompt' @@ -301,6 +393,44 @@ const useEmbedTab = () => { setEmbedCode(embed); }, }); + // Hydrate the custom launcher icon config from persisted mentor settings + // once they load. Without this, the config only ever lived in local state, so + // a refresh reverted the icon (and the generated embed snippet) to defaults. + // We guard with a ref keyed on the settings identity so hydration runs on the + // initial load / settings-id change only — never on every render — to avoid + // clobbering in-progress user edits. + const hydratedSettingsKeyRef = useRef(null); + useEffect(() => { + if (!mentorPublicSettings) return; + + const settings = mentorPublicSettings as { + mentor_unique_id?: string; + embed_icon_selection_data?: unknown; + embed_custom_image?: string | null; + }; + const settingsKey = settings.mentor_unique_id ?? mentorId; + if (hydratedSettingsKeyRef.current === settingsKey) return; + hydratedSettingsKeyRef.current = settingsKey; + + const hydrated = buildFloatingBubbleConfigFromSettings( + defaultFloatingBubbleConfig, + settings.embed_icon_selection_data, + settings.embed_custom_image, + ); + if (!hydrated) return; + + setCustomFloatingBubbleConfig(hydrated.config); + // NOTE: the `icon_selection` form field is NOT set here. It hydrates from + // settings via the form's reactive `defaultValues` (see the `useForm` block + // above). Setting it imperatively here would be clobbered by that reactive + // re-init when `mentorPublicSettings` loads, which was the root cause of the + // refresh bug (#789). `buildFloatingBubbleConfigFromSettings` still returns + // `iconSelection` for other consumers/tests; we simply don't apply it here. + // Intentionally keyed on settings identity only — `form` and + // `defaultFloatingBubbleConfig` are recreated each render but the ref guard + // ensures this hydrates once per settings load, not on every render. + }, [mentorPublicSettings, mentorId]); + const { data: integratedSsoProviders, isError: isIntegratedSsoProvidersError = true, diff --git a/components/modals/edit-mentor-modal/tabs/embed-tab.tsx b/components/modals/edit-mentor-modal/tabs/embed-tab.tsx index f86cbfbc..cc442fc8 100644 --- a/components/modals/edit-mentor-modal/tabs/embed-tab.tsx +++ b/components/modals/edit-mentor-modal/tabs/embed-tab.tsx @@ -964,7 +964,12 @@ export function EmbedTab() { Icon Selection