Add post-onboarding AI chat setup behind a feature flag (#23120)
https://github.com/user-attachments/assets/fec7076f-4e46-4c39-84d7-68e4340244ac After finishing onboarding, users now land in a full-screen AI chat that helps them set up their workspace, instead of going straight to their default view. The welcome overlay's title flies into the chat's first message so the handoff reads as one continuous motion: the slide plays alone, the title swaps in place pixel-exactly (a regular-weight clone of the target line is crossfaded in mid-flight to morph the font weight), then the rest of the text fades in. All of it sits behind `IS_ONBOARDING_AI_CHAT_ENABLED` (default off, not registered as a public flag). With the flag off, onboarding behaves exactly as it does today — the welcome overlay still plays and the user lands on their home view. Layout follows the Figma: the nav drawer stays visible and the chat renders in a panel-styled container with an "Onboarding" header, matching the expanded side panel. Also fixes two pre-existing bugs the feature surfaced: - On billing instances the completion redirect raced the lazy `PaymentSuccess` page, which silently skipped the welcome animation on the no-card trial path. The redirect now defers while a checkout is pending, and `PaymentSuccess` always confirms through `useLoadCurrentUser` so freshly served feature flags are respected. - `useDefaultHomePagePath` could conclude its `/settings/profile` empty-workspace fallback from a transiently empty metadata store and strand the user there; it now waits for both object metadata and navigation menu items before deciding. Reviewer notes: - `AgentChatRuntimeEffects` no longer keys off side-panel state, so `modules/ai` stops importing `modules/side-panel`. The two visibility-scoped effects moved into `AiChatTab`. - `/workspace-setup` is deliberately URL-addressable rather than onboarding-only: the collapse control in the header is a general expand/collapse toggle (paired with a new expand button in the side panel top bar), and gating the route would break refresh and browser-back. It is still authenticated-only. - The design's second, LLM-authored paragraph is not implemented — starting an assistant turn with no user message needs server-side work. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23120?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
+80
@@ -0,0 +1,80 @@
|
||||
import { getWelcomeTitleFlight } from '@/onboarding/utils/getWelcomeTitleFlight';
|
||||
|
||||
const buildRect = (
|
||||
left: number,
|
||||
top: number,
|
||||
width: number,
|
||||
height: number,
|
||||
): DOMRect =>
|
||||
({
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
right: left + width,
|
||||
bottom: top + height,
|
||||
x: left,
|
||||
y: top,
|
||||
}) as DOMRect;
|
||||
|
||||
describe('getWelcomeTitleFlight', () => {
|
||||
it('should translate from where the text starts, not from the pill edge', () => {
|
||||
const flight = getWelcomeTitleFlight({
|
||||
sourceRect: buildRect(100, 200, 400, 80),
|
||||
sourcePaddingLeftInPx: 32,
|
||||
sourceFontSizeInPx: 26,
|
||||
targetRect: buildRect(24, 40, 300, 24),
|
||||
targetFontSizeInPx: 26,
|
||||
});
|
||||
|
||||
expect(flight.translateXInPx).toBe(24 - (100 + 32));
|
||||
});
|
||||
|
||||
it('should align the vertical centers of source and target', () => {
|
||||
const flight = getWelcomeTitleFlight({
|
||||
sourceRect: buildRect(0, 200, 400, 80),
|
||||
sourcePaddingLeftInPx: 0,
|
||||
sourceFontSizeInPx: 26,
|
||||
targetRect: buildRect(0, 40, 300, 24),
|
||||
targetFontSizeInPx: 26,
|
||||
});
|
||||
|
||||
expect(flight.translateYInPx).toBe(52 - 240);
|
||||
});
|
||||
|
||||
it('should scale by the font size ratio', () => {
|
||||
const flight = getWelcomeTitleFlight({
|
||||
sourceRect: buildRect(0, 0, 400, 80),
|
||||
sourcePaddingLeftInPx: 0,
|
||||
sourceFontSizeInPx: 26,
|
||||
targetRect: buildRect(0, 0, 300, 24),
|
||||
targetFontSizeInPx: 13,
|
||||
});
|
||||
|
||||
expect(flight.scale).toBe(0.5);
|
||||
});
|
||||
|
||||
it('should anchor the transform origin on the text start', () => {
|
||||
const flight = getWelcomeTitleFlight({
|
||||
sourceRect: buildRect(0, 0, 400, 80),
|
||||
sourcePaddingLeftInPx: 32,
|
||||
sourceFontSizeInPx: 26,
|
||||
targetRect: buildRect(0, 0, 300, 24),
|
||||
targetFontSizeInPx: 26,
|
||||
});
|
||||
|
||||
expect(flight.transformOriginXInPx).toBe(32);
|
||||
});
|
||||
|
||||
it('should fall back to a neutral scale when the source font size is unreadable', () => {
|
||||
const flight = getWelcomeTitleFlight({
|
||||
sourceRect: buildRect(0, 0, 400, 80),
|
||||
sourcePaddingLeftInPx: 0,
|
||||
sourceFontSizeInPx: 0,
|
||||
targetRect: buildRect(0, 0, 300, 24),
|
||||
targetFontSizeInPx: 16,
|
||||
});
|
||||
|
||||
expect(flight.scale).toBe(1);
|
||||
});
|
||||
});
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
import { WELCOME_TITLE_HANDOFF_TARGET_ELEMENT_ID } from '@/onboarding/constants/WelcomeTitleHandoffTargetElementId';
|
||||
import { WELCOME_TITLE_SOURCE_ELEMENT_ID } from '@/onboarding/constants/WelcomeTitleSourceElementId';
|
||||
import { measureWelcomeTitleFlight } from '@/onboarding/utils/measureWelcomeTitleFlight';
|
||||
|
||||
const buildSourceElement = () => {
|
||||
const source = document.createElement('div');
|
||||
source.id = WELCOME_TITLE_SOURCE_ELEMENT_ID;
|
||||
source.getBoundingClientRect = () =>
|
||||
({ left: 100, top: 200, width: 400, height: 80 }) as DOMRect;
|
||||
document.body.appendChild(source);
|
||||
return source;
|
||||
};
|
||||
|
||||
const buildTargetElement = (rect: Partial<DOMRect>, visibility = 'visible') => {
|
||||
const target = document.createElement('span');
|
||||
target.id = WELCOME_TITLE_HANDOFF_TARGET_ELEMENT_ID;
|
||||
target.style.visibility = visibility;
|
||||
target.getBoundingClientRect = () =>
|
||||
({ left: 24, top: 40, width: 200, height: 20, ...rect }) as DOMRect;
|
||||
document.body.appendChild(target);
|
||||
return target;
|
||||
};
|
||||
|
||||
describe('measureWelcomeTitleFlight', () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('should return null when the source element is missing', () => {
|
||||
buildTargetElement({});
|
||||
|
||||
expect(measureWelcomeTitleFlight()).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when the target element is missing', () => {
|
||||
buildSourceElement();
|
||||
|
||||
expect(measureWelcomeTitleFlight()).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when the target has no width', () => {
|
||||
buildSourceElement();
|
||||
buildTargetElement({ width: 0 });
|
||||
|
||||
expect(measureWelcomeTitleFlight()).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when the target has no height', () => {
|
||||
buildSourceElement();
|
||||
buildTargetElement({ height: 0 });
|
||||
|
||||
expect(measureWelcomeTitleFlight()).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when the target is not visible', () => {
|
||||
buildSourceElement();
|
||||
buildTargetElement({}, 'hidden');
|
||||
|
||||
expect(measureWelcomeTitleFlight()).toBeNull();
|
||||
});
|
||||
|
||||
it('should measure a flight when both ends are laid out and visible', () => {
|
||||
buildSourceElement();
|
||||
buildTargetElement({});
|
||||
|
||||
expect(measureWelcomeTitleFlight()).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { type WelcomeTitleFlight } from '@/onboarding/types/WelcomeTitleFlight';
|
||||
|
||||
type GetWelcomeTitleFlightArgs = {
|
||||
sourceRect: DOMRect;
|
||||
sourcePaddingLeftInPx: number;
|
||||
sourceFontSizeInPx: number;
|
||||
targetRect: DOMRect;
|
||||
targetFontSizeInPx: number;
|
||||
};
|
||||
|
||||
export const getWelcomeTitleFlight = ({
|
||||
sourceRect,
|
||||
sourcePaddingLeftInPx,
|
||||
sourceFontSizeInPx,
|
||||
targetRect,
|
||||
targetFontSizeInPx,
|
||||
}: GetWelcomeTitleFlightArgs): WelcomeTitleFlight => {
|
||||
const sourceTextLeft = sourceRect.left + sourcePaddingLeftInPx;
|
||||
const sourceCenterY = sourceRect.top + sourceRect.height / 2;
|
||||
const targetCenterY = targetRect.top + targetRect.height / 2;
|
||||
|
||||
return {
|
||||
translateXInPx: targetRect.left - sourceTextLeft,
|
||||
translateYInPx: targetCenterY - sourceCenterY,
|
||||
scale: sourceFontSizeInPx > 0 ? targetFontSizeInPx / sourceFontSizeInPx : 1,
|
||||
transformOriginXInPx: sourcePaddingLeftInPx,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { WELCOME_TITLE_HANDOFF_TARGET_ELEMENT_ID } from '@/onboarding/constants/WelcomeTitleHandoffTargetElementId';
|
||||
import { WELCOME_TITLE_SOURCE_ELEMENT_ID } from '@/onboarding/constants/WelcomeTitleSourceElementId';
|
||||
import { type WelcomeTitleFlight } from '@/onboarding/types/WelcomeTitleFlight';
|
||||
import { getWelcomeTitleFlight } from '@/onboarding/utils/getWelcomeTitleFlight';
|
||||
|
||||
const isElementLaidOutAndVisible = (
|
||||
rect: DOMRect,
|
||||
style: CSSStyleDeclaration,
|
||||
) => rect.width > 0 && rect.height > 0 && style.visibility === 'visible';
|
||||
|
||||
export const measureWelcomeTitleFlight = (): WelcomeTitleFlight | null => {
|
||||
const sourceElement = document.getElementById(
|
||||
WELCOME_TITLE_SOURCE_ELEMENT_ID,
|
||||
);
|
||||
const targetElement = document.getElementById(
|
||||
WELCOME_TITLE_HANDOFF_TARGET_ELEMENT_ID,
|
||||
);
|
||||
|
||||
if (!isDefined(sourceElement) || !isDefined(targetElement)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sourceStyle = window.getComputedStyle(sourceElement);
|
||||
const targetStyle = window.getComputedStyle(targetElement);
|
||||
const sourceRect = sourceElement.getBoundingClientRect();
|
||||
const targetRect = targetElement.getBoundingClientRect();
|
||||
|
||||
if (
|
||||
!isElementLaidOutAndVisible(sourceRect, sourceStyle) ||
|
||||
!isElementLaidOutAndVisible(targetRect, targetStyle)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getWelcomeTitleFlight({
|
||||
sourceRect,
|
||||
sourcePaddingLeftInPx: parseFloat(sourceStyle.paddingLeft),
|
||||
sourceFontSizeInPx: parseFloat(sourceStyle.fontSize),
|
||||
targetRect,
|
||||
targetFontSizeInPx: parseFloat(targetStyle.fontSize),
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user