Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,11 @@ Never set a custom font family. The plugin must feel native to the admin environ
### General rule
Use `@wordpress/components` for every interactive element. Do not reach for raw `<button>` or `<input>` elements unless unavoidable (e.g. hidden file inputs). Using WordPress components ensures keyboard accessibility, focus management, and visual consistency come for free.

### Automatic onboarding
- When checkout or another trusted launch path opens the Setup Assistant, start its agent loop immediately after providers and sessions load.
- Do not flash generic greeting or suggestion cards while the automatic kickoff is opening or recovering an empty bootstrap session. Show the onboarding status and then the real conversation.
- Reopening a populated bootstrap session must never submit a duplicate kickoff.

### Privacy review screens
- Use a compact filterable summary list with an explicit detail selection; never place transcript text, profile identifiers, tokens, hashes, tool data, or raw provider payloads in list rows.
- Keep transcript detail text-only, bounded, and visibly separate from its summary. Use pagination to load earlier retained messages instead of rendering an unbounded conversation at once.
Expand Down
18 changes: 18 additions & 0 deletions includes/Core/OnboardingManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,7 @@ public static function rest_start(): \WP_REST_Response|\WP_Error {
'session_id' => $existing_session_id ?: null,
'agent_id' => $onboarding_agent_id,
'kickoff_message' => $kickoff_message,
'kickoff_required' => self::session_needs_kickoff( (int) $existing_session_id ),
],
200
);
Expand Down Expand Up @@ -452,12 +453,29 @@ public static function rest_start(): \WP_REST_Response|\WP_Error {
'session_id' => $session_id,
'agent_id' => $onboarding_agent_id,
'kickoff_message' => $kickoff_message,
'kickoff_required' => true,
'woo_detected' => $woo_active,
],
200
);
}

/**
* Whether the persisted bootstrap session still needs its automatic first turn.
*
* @param int $session_id Bootstrap session ID.
*/
private static function session_needs_kickoff( int $session_id ): bool {
if ( $session_id <= 0 ) {
return false;
}

$session = Database::get_session( $session_id );
$messages = $session ? json_decode( (string) $session->messages, true ) : null;

return is_array( $messages ) && [] === $messages;
}

/**
* Share the site-wide onboarding session with admins.
*
Expand Down
5 changes: 4 additions & 1 deletion src/components/chat-widget/widget-panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,10 @@ export default function WidgetPanel( {
[ isMinimized, dragMoved, setFloatingMinimized ]
);

const showEmpty = messageCount === 0 && ! sending;
// Automatic onboarding owns the first turn. Do not flash the generic greeting
// and suggestion cards while the bootstrap session opens and sends its kickoff.
const showEmpty =
messageCount === 0 && ! sending && ! frontendOnboardingMode;

const panelStyle = {};
if ( position && ! frontendOnboardingMode ) {
Expand Down
108 changes: 46 additions & 62 deletions src/floating-widget/__tests__/frontend-onboarding.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,14 @@ import {
getHydrationSessionId,
hasLiveSiteChangeActivity,
isFrontendOnboardingEnabled,
isMobileViewport,
shouldHydrateSession,
shouldStartFrontendOnboarding,
startOnboarding,
} from '../frontend-onboarding';

describe( 'frontend onboarding helpers', () => {
beforeEach( () => {
document.body.className = '';
window.history.pushState( {}, '', '/' );
Object.defineProperty( window, 'matchMedia', {
configurable: true,
writable: true,
value: undefined,
} );
} );

test( 'enables onboarding only for incomplete frontend pages', () => {
Expand Down Expand Up @@ -60,19 +53,6 @@ describe( 'frontend onboarding helpers', () => {
).toBe( false );
} );

test( 'detects mobile viewport preference', () => {
Object.defineProperty( window, 'matchMedia', {
configurable: true,
writable: true,
value: jest.fn().mockReturnValue( { matches: true } ),
} );

expect( isMobileViewport() ).toBe( true );
expect( window.matchMedia ).toHaveBeenCalledWith(
'(max-width: 600px)'
);
} );

test( 'detects live site-change activity from affected tool responses', () => {
expect(
hasLiveSiteChangeActivity( [
Expand Down Expand Up @@ -137,44 +117,6 @@ describe( 'frontend onboarding helpers', () => {
).toBe( true );
} );

test( 'starts frontend onboarding only after sessions load empty', () => {
expect(
shouldStartFrontendOnboarding( {
enabled: true,
started: false,
providersLoaded: true,
providerCount: 1,
sessionsLoaded: false,
sessionCount: 0,
currentSessionId: null,
} )
).toBe( false );

expect(
shouldStartFrontendOnboarding( {
enabled: true,
started: false,
providersLoaded: true,
providerCount: 1,
sessionsLoaded: true,
sessionCount: 1,
currentSessionId: null,
} )
).toBe( false );

expect(
shouldStartFrontendOnboarding( {
enabled: true,
started: false,
providersLoaded: true,
providerCount: 1,
sessionsLoaded: true,
sessionCount: 0,
currentSessionId: null,
} )
).toBe( true );
} );

test( 'starts unified onboarding from the frontend', async () => {
const apiFetch = jest.fn().mockResolvedValueOnce( {
agent_id: 7,
Expand All @@ -190,7 +132,6 @@ describe( 'frontend onboarding helpers', () => {
openSession,
sendMessage,
setSelectedAgentId,
fallbackMessage: 'Fallback',
} );

expect( apiFetch ).toHaveBeenCalledWith( {
Expand All @@ -202,6 +143,51 @@ describe( 'frontend onboarding helpers', () => {
expect( sendMessage ).toHaveBeenCalledWith( 'Welcome' );
} );

test( 'restarts an existing empty onboarding session', async () => {
const apiFetch = jest.fn().mockResolvedValueOnce( {
agent_id: 7,
session_id: 42,
kickoff_message: 'Welcome',
already_complete: true,
kickoff_required: true,
} );
const openSession = jest.fn().mockResolvedValue( undefined );
const sendMessage = jest.fn().mockResolvedValue( undefined );

const result = await startOnboarding( {
apiFetch,
openSession,
sendMessage,
setSelectedAgentId: jest.fn(),
} );

expect( sendMessage ).toHaveBeenCalledWith( 'Welcome' );
expect( result ).toBe( true );
} );

test( 'does not duplicate kickoff in a populated onboarding session', async () => {
const apiFetch = jest.fn().mockResolvedValueOnce( {
agent_id: 7,
session_id: 42,
kickoff_message: 'Welcome',
already_complete: true,
kickoff_required: false,
} );
const openSession = jest.fn().mockResolvedValue( undefined );
const sendMessage = jest.fn().mockResolvedValue( undefined );

const result = await startOnboarding( {
apiFetch,
openSession,
sendMessage,
setSelectedAgentId: jest.fn(),
} );

expect( openSession ).toHaveBeenCalledWith( 42 );
expect( sendMessage ).not.toHaveBeenCalled();
expect( result ).toBe( false );
} );

test( 'returns null when onboarding start omits a session id', async () => {
const apiFetch = jest.fn().mockResolvedValueOnce( {
agent_id: 7,
Expand All @@ -215,7 +201,6 @@ describe( 'frontend onboarding helpers', () => {
openSession,
sendMessage,
setSelectedAgentId: jest.fn(),
fallbackMessage: 'Fallback',
} )
).resolves.toBeNull();

Expand All @@ -235,10 +220,9 @@ describe( 'frontend onboarding helpers', () => {
openSession,
sendMessage,
setSelectedAgentId: jest.fn(),
fallbackMessage: 'Fallback',
} );

expect( openSession ).toHaveBeenCalledWith( 42 );
expect( sendMessage ).toHaveBeenCalledWith( 'Fallback' );
expect( sendMessage ).toHaveBeenCalledWith( 'Start setup.' );
} );
} );
63 changes: 7 additions & 56 deletions src/floating-widget/frontend-onboarding.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,18 +53,6 @@ export function isFrontendOnboardingEnabled( data ) {
return ! isAdmin;
}

/**
* Detect whether the viewport should prefer a minimized mobile build view.
*
* @return {boolean} True for narrow screens.
*/
export function isMobileViewport() {
return (
typeof window !== 'undefined' &&
window.matchMedia?.( '(max-width: 600px)' )?.matches === true
);
}

/**
* Whether live job activity contains a site-mutating response.
*
Expand Down Expand Up @@ -169,43 +157,6 @@ export function openHydrated( sessions, sessionJobs, openSession, isCurrent ) {
return sessionId;
}

/**
* Determine whether first-run frontend onboarding may start.
*
* Onboarding must wait until sessions have loaded. Otherwise a reload during a
* real submitted build can briefly look empty and bootstrap a fresh setup
* session before the existing conversation list arrives.
*
* @param {Object} options
* @param {boolean} options.enabled Frontend onboarding flag.
* @param {boolean} options.started Whether this page already started onboarding.
* @param {boolean} options.providersLoaded Whether providers finished loading.
* @param {number} options.providerCount Number of available providers.
* @param {boolean} options.sessionsLoaded Whether sessions finished loading.
* @param {number} options.sessionCount Number of existing sessions.
* @param {?number} options.currentSessionId Currently opened session ID.
* @return {boolean} True when it is safe to create the setup session.
*/
export function shouldStartFrontendOnboarding( {
enabled,
started,
providersLoaded,
providerCount,
sessionsLoaded,
sessionCount,
currentSessionId,
} ) {
return (
!! enabled &&
! started &&
!! providersLoaded &&
providerCount > 0 &&
!! sessionsLoaded &&
sessionCount === 0 &&
! currentSessionId
);
}

/**
* Start frontend onboarding and send its first message when appropriate.
*
Expand All @@ -214,15 +165,13 @@ export function shouldStartFrontendOnboarding( {
* @param {Function} options.openSession Store action to open a session.
* @param {Function} options.sendMessage Store action to send a message.
* @param {Function} options.setSelectedAgentId Store action to select an agent.
* @param {string} options.fallbackMessage Message used when REST omits one.
* @return {Promise<Object|null>} Start metadata, or null if no session returned.
* @return {Promise<boolean|null>} Whether a kickoff was sent, or null without a session.
*/
export async function startOnboarding( {
apiFetch = defaultApiFetch,
openSession,
sendMessage,
setSelectedAgentId,
fallbackMessage = "Hi! I'm ready to set up this site.",
} ) {
const data = await apiFetch( {
path: ONBOARDING_START_PATH,
Expand All @@ -239,9 +188,11 @@ export async function startOnboarding( {

await openSession( data.session_id );

await sendMessage( data.kickoff_message || fallbackMessage );
const shouldSendKickoff = data.kickoff_required !== false;

if ( shouldSendKickoff ) {
await sendMessage( data.kickoff_message || 'Start setup.' );
}

return {
data,
};
return shouldSendKickoff;
}
11 changes: 7 additions & 4 deletions src/floating-widget/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ function FloatingWidget() {
useEffect( () => {
let active = true;
if (
frontendOnboardingMode ||
! sessionsLoaded ||
( ! sessions.length && providers.length > 0 )
) {
Expand All @@ -158,6 +159,7 @@ function FloatingWidget() {
active = false;
};
}, [
frontendOnboardingMode,
providers.length,
sessionsLoaded,
sessions,
Expand All @@ -170,12 +172,11 @@ function FloatingWidget() {
useEffect( () => {
if (
! sessionsLoaded ||
sessions.length ||
! providers.length ||
! frontendOnboardingEnabled ||
frontendOnboardingStartedRef.current ||
! providersLoaded ||
currentSessionId
isNewChatPending
) {
return;
}
Expand All @@ -189,6 +190,10 @@ function FloatingWidget() {
setSelectedAgentId,
} )
)
.then(
( kickoffSent ) =>
kickoffSent || setFrontendOnboardingMode( null )
)
.catch( () => {
setFrontendOnboardingMode( null );
} );
Expand All @@ -197,8 +202,6 @@ function FloatingWidget() {
providersLoaded,
providers.length,
sessionsLoaded,
sessions.length,
currentSessionId,
isNewChatPending,
openSession,
sendMessage,
Expand Down
Loading
Loading