Skip to content
Draft
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
12 changes: 11 additions & 1 deletion apps/studio/src/modules/user-settings/lib/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,17 @@ async function persistOnboardingHints( partial: Partial< OnboardingHintsState >
await lockAppdata();
try {
const userData = await loadUserData();
const merged: OnboardingHintsState = { ...( userData.onboardingHints ?? {} ), ...partial };
const current = userData.onboardingHints ?? {};
// Merge completedItems by key so a checklist completion never clobbers a
// concurrent one (a plain spread would replace the whole map).
const merged: OnboardingHintsState = {
...current,
...partial,
completedItems: { ...current.completedItems, ...partial.completedItems },
};
if ( ! merged.completedItems || Object.keys( merged.completedItems ).length === 0 ) {
delete merged.completedItems;
}
await saveUserData( { ...userData, onboardingHints: merged } );
} finally {
await unlockAppdata();
Expand Down
6 changes: 5 additions & 1 deletion apps/studio/src/storage/storage-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,15 @@ export interface PromptWindowsSpeedUpResult {

// Mirror of the renderer's OnboardingHintsState (apps/ui/src/data/core/types.ts).
// Persisted verbatim; the desktop never inspects it, so a structural shape keeps
// the two sides decoupled.
// the two sides decoupled (completedItems keys are the renderer's ChecklistItemId).
export interface OnboardingHintsState {
tourCompletedVersion?: number;
tourDismissedVersion?: number;
migratedFromClassic?: boolean;
checklistDismissed?: boolean;
checklistMinimized?: boolean;
completedItems?: Record< string, string >;
publishCoachmarkShown?: boolean;
}

export const EMPTY_USER_DATA: UserData = {
Expand Down
19 changes: 18 additions & 1 deletion apps/ui/src/app/app-providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ import { I18nProvider } from '@wordpress/react-i18n';
import { privateApis } from '@wordpress/theme';
import { Tooltip } from '@wordpress/ui';
import { useEffect } from 'react';
import { CoachmarkAnchorProvider } from '@/components/coachmarks/anchor-registry';
import { CoachmarkProvider } from '@/components/coachmarks/coachmark-provider';
import { OnboardingGuideProvider } from '@/components/onboarding-guide/use-onboarding-guide';
import { ConnectorProvider, queryClient } from '@/data/core';
import { useOnboardingEvents } from '@/data/onboarding/use-onboarding-events';
import { AgentRunProvider } from '@/data/queries/use-agent-run';
import { useSyncAppUpdateStatus } from '@/data/queries/use-app-update';
import { useSyncSessionsWithEvents } from '@/data/queries/use-sessions';
Expand Down Expand Up @@ -35,6 +38,13 @@ function SiteEventsBridge() {
// query providers so it can read the saved color-scheme preference (not just
// the OS setting), which is what makes the in-app dark/light toggle work in the
// browser, where there's no Electron `nativeTheme` to mirror it.
// App-wide onboarding completion watchers (first agent edit, publish). Lives
// inside the coachmark provider so it can fire the one-shot publish coachmark.
function OnboardingWatchers() {
useOnboardingEvents();
return null;
}

function ThemedApp( { children }: PropsWithChildren ) {
const colorScheme = useColorScheme();
const themeColor = colorScheme === 'dark' ? { bg: '#1e1e1e' } : undefined;
Expand All @@ -44,7 +54,14 @@ function ThemedApp( { children }: PropsWithChildren ) {
return (
<ThemeProvider isRoot color={ themeColor } density="compact">
<Tooltip.Provider>
<OnboardingGuideProvider>{ children }</OnboardingGuideProvider>
<OnboardingGuideProvider>
<CoachmarkAnchorProvider>
<CoachmarkProvider>
<OnboardingWatchers />
{ children }
</CoachmarkProvider>
</CoachmarkAnchorProvider>
</OnboardingGuideProvider>
</Tooltip.Provider>
</ThemeProvider>
);
Expand Down
88 changes: 88 additions & 0 deletions apps/ui/src/components/coachmarks/anchor-registry.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { render } from '@testing-library/react';
import { useEffect } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { CoachmarkAnchorProvider, useAnchorRegistry, useTourAnchor } from './anchor-registry';
import type { CoachmarkAnchorId } from '@/data/onboarding/types';

interface AnchorRegistryHandle {
getElement( id: CoachmarkAnchorId ): HTMLElement | null;
subscribe( listener: () => void ): () => void;
}

// A visible element (getBoundingClientRect stubbed) registered under an id.
function Anchor( { id, size = 20 }: { id: 'composer'; size?: number } ) {
const ref = useTourAnchor( id );
return (
<div
ref={ ( element ) => {
if ( element ) {
element.getBoundingClientRect = () =>
( {
width: size,
height: size,
x: 0,
y: 0,
top: 0,
left: 0,
right: size,
bottom: size,
toJSON() {},
} ) as DOMRect;
}
ref( element );
} }
/>
);
}

function Capture( { onReady }: { onReady: ( registry: AnchorRegistryHandle ) => void } ) {
const registry = useAnchorRegistry();
useEffect( () => {
onReady( registry as unknown as AnchorRegistryHandle );
}, [ registry, onReady ] );
return null;
}

describe( 'anchor registry', () => {
it( 'resolves a registered, laid-out element', () => {
let registry: AnchorRegistryHandle | null = null;
render(
<CoachmarkAnchorProvider>
<Capture onReady={ ( value ) => ( registry = value ) } />
<Anchor id="composer" />
</CoachmarkAnchorProvider>
);
expect( registry!.getElement( 'composer' ) ).not.toBeNull();
} );

it( 'treats a zero-size (collapsed) element as unavailable', () => {
let registry: AnchorRegistryHandle | null = null;
render(
<CoachmarkAnchorProvider>
<Capture onReady={ ( value ) => ( registry = value ) } />
<Anchor id="composer" size={ 0 } />
</CoachmarkAnchorProvider>
);
expect( registry!.getElement( 'composer' ) ).toBeNull();
} );

it( 'unregisters on unmount and notifies subscribers', () => {
let registry: AnchorRegistryHandle | null = null;
const { rerender } = render(
<CoachmarkAnchorProvider>
<Capture onReady={ ( value ) => ( registry = value ) } />
<Anchor id="composer" />
</CoachmarkAnchorProvider>
);
const listener = vi.fn();
const unsubscribe = registry!.subscribe( listener );
rerender(
<CoachmarkAnchorProvider>
<Capture onReady={ ( value ) => ( registry = value ) } />
</CoachmarkAnchorProvider>
);
expect( registry!.getElement( 'composer' ) ).toBeNull();
expect( listener ).toHaveBeenCalled();
unsubscribe();
} );
} );
124 changes: 124 additions & 0 deletions apps/ui/src/components/coachmarks/anchor-registry.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { createContext, useCallback, useContext, useMemo, useRef } from 'react';
import type { CoachmarkAnchorId } from '@/data/onboarding/types';
import type { ReactNode } from 'react';

// A live map of coachmark-anchor id → registered DOM element(s), following the
// Sentry GuideAnchor pattern: components register their element on mount, so
// "is the target on screen yet?" is answered by the registry rather than by
// brittle document.querySelector polling. An element that is registered but
// laid out at zero size (e.g. a collapsed sidebar) counts as unavailable.

interface AnchorRegistry {
register( id: CoachmarkAnchorId, element: HTMLElement ): () => void;
getElement( id: CoachmarkAnchorId ): HTMLElement | null;
subscribe( listener: () => void ): () => void;
}

const AnchorRegistryContext = createContext< AnchorRegistry | null >( null );

function isLaidOut( element: HTMLElement ): boolean {
const rect = element.getBoundingClientRect();
return rect.width > 4 && rect.height > 4;
}

export function CoachmarkAnchorProvider( { children }: { children: ReactNode } ) {
const mapRef = useRef< Map< CoachmarkAnchorId, Set< HTMLElement > > >( new Map() );
const listenersRef = useRef< Set< () => void > >( new Set() );

const notify = useCallback( () => {
for ( const listener of listenersRef.current ) {
listener();
}
}, [] );

const registry = useMemo< AnchorRegistry >(
() => ( {
register( id, element ) {
let set = mapRef.current.get( id );
if ( ! set ) {
set = new Set();
mapRef.current.set( id, set );
}
set.add( element );
notify();
return () => {
const current = mapRef.current.get( id );
if ( ! current ) {
return;
}
current.delete( element );
if ( current.size === 0 ) {
mapRef.current.delete( id );
}
notify();
};
},
getElement( id ) {
const set = mapRef.current.get( id );
if ( set ) {
// Most recently registered laid-out element wins (Set keeps
// insertion order), so a duplicate anchor mounted in a newer
// panel supersedes a stale one.
let match: HTMLElement | null = null;
for ( const element of set ) {
if ( isLaidOut( element ) ) {
match = element;
}
}
if ( match ) {
return match;
}
}
// Fallback for targets we can't wrap with the hook.
const fallback = document.querySelector< HTMLElement >( `[data-tour-id="${ id }"]` );
return fallback && isLaidOut( fallback ) ? fallback : null;
},
subscribe( listener ) {
listenersRef.current.add( listener );
return () => {
listenersRef.current.delete( listener );
};
},
} ),
[ notify ]
);

return (
<AnchorRegistryContext.Provider value={ registry }>{ children }</AnchorRegistryContext.Provider>
);
}

export function useAnchorRegistry(): AnchorRegistry {
const registry = useContext( AnchorRegistryContext );
if ( ! registry ) {
throw new Error( 'useAnchorRegistry must be used within a CoachmarkAnchorProvider' );
}
return registry;
}

/**
* Returns a ref callback that registers its element as the given coachmark
* anchor. Safe to use without a provider (returns a no-op), so instrumented
* components render fine in isolation/tests.
*/
export function useTourAnchor(
id: CoachmarkAnchorId,
options?: { disabled?: boolean }
): ( element: HTMLElement | null ) => void {
const registry = useContext( AnchorRegistryContext );
const disabled = options?.disabled ?? false;
const cleanupRef = useRef< ( () => void ) | null >( null );

return useCallback(
( element: HTMLElement | null ) => {
if ( cleanupRef.current ) {
cleanupRef.current();
cleanupRef.current = null;
}
if ( registry && element && ! disabled ) {
cleanupRef.current = registry.register( id, element );
}
},
[ registry, id, disabled ]
);
}
91 changes: 91 additions & 0 deletions apps/ui/src/components/coachmarks/coachmark-bubble.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { Popover } from '@base-ui/react/popover';
import { __ } from '@wordpress/i18n';
import { privateApis } from '@wordpress/theme';
import { Button } from '@wordpress/ui';
import motionStyles from '@/components/floating-surface-motion/style.module.css';
import { unlock } from '@/lock-unlock';
import styles from './style.module.css';
import type { CoachmarkPlacement } from '@/data/onboarding/types';
import type { ReactNode } from 'react';

const { ThemeProvider } = unlock( privateApis );

interface CoachmarkBubbleProps {
anchorElement: HTMLElement;
title: string;
description: ReactNode;
placement: CoachmarkPlacement;
onDismiss: () => void;
}

// A single arrowed bubble that tracks its anchor. Non-modal, so the app stays
// interactive behind it — the bubble teaches where to click without a scrim.
export function CoachmarkBubble( {
anchorElement,
title,
description,
placement,
onDismiss,
}: CoachmarkBubbleProps ) {
return (
<Popover.Root
open
modal={ false }
onOpenChange={ ( open ) => {
// Any close — Esc, the close button, or a press anywhere else —
// dismisses. A coachmark is ephemeral; clicking on is the goal.
if ( ! open ) {
onDismiss();
}
} }
>
<Popover.Portal>
<Popover.Positioner
anchor={ anchorElement }
side={ placement.side }
align={ placement.align }
sideOffset={ 12 }
className={ styles.positioner }
>
{ /* Re-establish the density context lost when portaling to
document.body, same as components/menu. */ }
<ThemeProvider density="compact">
<Popover.Popup
className={ `${ styles.card } ${ motionStyles.motion }` }
aria-label={ title }
>
{ /* Pointer toward the anchor. The positioner tracks its
inline position; per-side offset/rotation is CSS. */ }
<Popover.Arrow className={ styles.arrow }>
<svg width="16" height="8" viewBox="0 0 16 8" aria-hidden="true">
<path className={ styles.arrowShape } d="M0 8 L8 1 L16 8" />
</svg>
</Popover.Arrow>
<div className={ styles.cardBody }>
<h2 className={ styles.cardTitle }>{ title }</h2>
<div className={ styles.cardDescription }>{ description }</div>
</div>
<div className={ styles.cardFooter }>
<div className={ styles.cardActions }>
<Button size="small" variant="solid" tone="brand" onClick={ onDismiss }>
{ __( 'Got it' ) }
</Button>
</div>
</div>
<Popover.Close className={ styles.cardClose } aria-label={ __( 'Dismiss' ) }>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M6 6l12 12M18 6L6 18"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
</svg>
</Popover.Close>
</Popover.Popup>
</ThemeProvider>
</Popover.Positioner>
</Popover.Portal>
</Popover.Root>
);
}
Loading