diff --git a/apps/studio/src/ipc-handlers.ts b/apps/studio/src/ipc-handlers.ts
index 96401ee5d1..4659c857bd 100644
--- a/apps/studio/src/ipc-handlers.ts
+++ b/apps/studio/src/ipc-handlers.ts
@@ -167,6 +167,7 @@ import { isStudioCliInstalled } from 'src/modules/cli/lib/ipc-handlers';
import { STABLE_BIN_DIR_PATH } from 'src/modules/cli/lib/windows-installation-manager';
import { supportedEditorConfig, SupportedEditor } from 'src/modules/user-settings/lib/editor';
import {
+ recordAgenticUiMigration,
getUserEditor,
getUserTerminal,
getDefaultSiteDirectory,
@@ -240,6 +241,7 @@ export {
getColorScheme,
getGlobalAgentInstructions,
getInstalledAppsAndTerminals,
+ getOnboardingHints,
getQuitSitesBehavior,
getUserEditor,
getUserLocale,
@@ -250,6 +252,7 @@ export {
saveAnalyticsEnabled,
saveColorScheme,
saveGlobalAgentInstructions,
+ saveOnboardingHints,
saveQuitSitesBehavior,
saveUserEditor,
saveUserLocale,
@@ -1531,6 +1534,10 @@ export async function getBetaFeatures( _event: IpcMainInvokeEvent ): Promise< Be
export async function enableAgenticUi( _event: IpcMainInvokeEvent ): Promise< void > {
await updateBetaFeatureInLib( 'enableAgenticUi', true );
setAgenticUiEnabled( true );
+ // Opting in from classic Studio is the sole way an existing user reaches the
+ // agentic workbench, so record it here for the orientation guide's migrating
+ // copy. Must land before the renderer reloads below so the guide sees it.
+ await recordAgenticUiMigration();
const mainWindow = await getMainWindow();
if ( mainWindow && ! mainWindow.isDestroyed() ) {
await loadMainWindowRenderer( mainWindow );
diff --git a/apps/studio/src/ipc-utils.ts b/apps/studio/src/ipc-utils.ts
index 2c17fa75ea..9793542d64 100644
--- a/apps/studio/src/ipc-utils.ts
+++ b/apps/studio/src/ipc-utils.ts
@@ -46,6 +46,7 @@ export interface IpcEvents {
'snapshot-key-value': [ { operationId: crypto.UUID; data: SnapshotKeyValueEventData } ];
'snapshot-success': [ { operationId: crypto.UUID } ];
'show-whats-new': [ void ];
+ 'show-getting-started': [ void ];
'sync-connect-site': [
{
remoteSiteId: number;
diff --git a/apps/studio/src/menu.ts b/apps/studio/src/menu.ts
index d3faf195d9..153ee585f5 100644
--- a/apps/studio/src/menu.ts
+++ b/apps/studio/src/menu.ts
@@ -424,6 +424,13 @@ async function getAppMenu(
},
enabled: ! needsOnboarding,
},
+ {
+ label: __( 'Getting Started' ),
+ click: async () => {
+ void sendIpcEventToRenderer( 'show-getting-started' );
+ },
+ enabled: ! needsOnboarding,
+ },
{ type: 'separator' },
...( process.platform === 'win32'
? [
diff --git a/apps/studio/src/modules/user-settings/lib/ipc-handlers.ts b/apps/studio/src/modules/user-settings/lib/ipc-handlers.ts
index 7b8ff9aa8c..f18e7b2b5b 100644
--- a/apps/studio/src/modules/user-settings/lib/ipc-handlers.ts
+++ b/apps/studio/src/modules/user-settings/lib/ipc-handlers.ts
@@ -13,6 +13,7 @@ import { SUPPORTED_EDITORS, SupportedEditor } from 'src/modules/user-settings/li
import { SupportedTerminal } from 'src/modules/user-settings/lib/terminal';
import { UserSettingsTabName } from 'src/modules/user-settings/user-settings-types';
import { defaultSitePath, ensureWritableDirectory } from 'src/storage/paths';
+import { OnboardingHintsState } from 'src/storage/storage-types';
import {
loadUserData,
lockAppdata,
@@ -195,6 +196,42 @@ export async function getWapuuScore(): Promise< number | undefined > {
return userData.wapuuScore;
}
+// Agentic UI onboarding state (orientation guide seen-state, migration marker).
+// The blob is opaque to the desktop; the renderer owns its meaning.
+export async function getOnboardingHints(): Promise< OnboardingHintsState > {
+ const userData = await loadUserData();
+ return userData.onboardingHints ?? {};
+}
+
+async function persistOnboardingHints( partial: Partial< OnboardingHintsState > ): Promise< void > {
+ if ( ! partial || typeof partial !== 'object' ) {
+ return;
+ }
+ await lockAppdata();
+ try {
+ const userData = await loadUserData();
+ const merged: OnboardingHintsState = { ...( userData.onboardingHints ?? {} ), ...partial };
+ await saveUserData( { ...userData, onboardingHints: merged } );
+ } finally {
+ await unlockAppdata();
+ }
+}
+
+export async function saveOnboardingHints(
+ _event: IpcMainInvokeEvent,
+ partial: Partial< OnboardingHintsState >
+): Promise< void > {
+ await persistOnboardingHints( partial );
+}
+
+// Marks that the user reached the agentic workbench by opting in from classic
+// Studio, so the orientation guide can greet them as a migrating user. Fresh
+// installs get the agentic UI seeded on by default (migration 09) and never
+// hit this path, so they stay "new".
+export async function recordAgenticUiMigration(): Promise< void > {
+ await persistOnboardingHints( { migratedFromClassic: true } );
+}
+
export async function getGlobalAgentInstructions(): Promise< string > {
return ( await readGlobalInstructionsFile() ) ?? '';
}
diff --git a/apps/studio/src/preload.ts b/apps/studio/src/preload.ts
index 5bab9cce7e..2ccbb330e3 100644
--- a/apps/studio/src/preload.ts
+++ b/apps/studio/src/preload.ts
@@ -179,6 +179,8 @@ const api: IpcApi = {
getAgenticFeaturesEnabled: () => ipcRendererInvoke( 'getAgenticFeaturesEnabled' ),
saveWapuuScore: ( score ) => ipcRendererInvoke( 'saveWapuuScore', score ),
getWapuuScore: () => ipcRendererInvoke( 'getWapuuScore' ),
+ getOnboardingHints: () => ipcRendererInvoke( 'getOnboardingHints' ),
+ saveOnboardingHints: ( partial ) => ipcRendererInvoke( 'saveOnboardingHints', partial ),
getUserEditor: () => ipcRendererInvoke( 'getUserEditor' ),
saveUserEditor: ( editor ) => ipcRendererInvoke( 'saveUserEditor', editor ),
comparePaths: ( path1, path2 ) => ipcRendererInvoke( 'comparePaths', path1, path2 ),
diff --git a/apps/studio/src/storage/storage-types.ts b/apps/studio/src/storage/storage-types.ts
index a5acd98b7e..d576f5d91d 100644
--- a/apps/studio/src/storage/storage-types.ts
+++ b/apps/studio/src/storage/storage-types.ts
@@ -57,6 +57,8 @@ export interface UserData {
// Whether chat/agent features are offered inside the new UI. Distinct from
// `betaFeatures.enableAgenticUi`, which picks the renderer (new vs classic).
agenticFeaturesEnabled?: boolean;
+ /** Agentic UI onboarding state (orientation tour, getting-started checklist). Opaque blob owned by the renderer. */
+ onboardingHints?: OnboardingHintsState;
}
export interface PromptWindowsSpeedUpResult {
@@ -65,6 +67,15 @@ export interface PromptWindowsSpeedUpResult {
dontAskAgain: boolean;
}
+// 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.
+export interface OnboardingHintsState {
+ tourCompletedVersion?: number;
+ tourDismissedVersion?: number;
+ migratedFromClassic?: boolean;
+}
+
export const EMPTY_USER_DATA: UserData = {
version: 1,
siteMetadata: {},
diff --git a/apps/studio/src/storage/user-data.ts b/apps/studio/src/storage/user-data.ts
index d7b1d130d3..8e56e7128c 100644
--- a/apps/studio/src/storage/user-data.ts
+++ b/apps/studio/src/storage/user-data.ts
@@ -83,6 +83,7 @@ type UserDataSafeKeys =
| 'cliAutoInstalled'
| 'cliUserUninstalled'
| 'wapuuScore'
+ | 'onboardingHints'
| 'lastNightlyUpdateCheck'
| 'nightlyPromptResult'
| 'agenticUiBannerDismissed'
diff --git a/apps/ui/src/app/app-providers.tsx b/apps/ui/src/app/app-providers.tsx
index 158ffb3555..a2157a3908 100644
--- a/apps/ui/src/app/app-providers.tsx
+++ b/apps/ui/src/app/app-providers.tsx
@@ -4,6 +4,7 @@ import { I18nProvider } from '@wordpress/react-i18n';
import { privateApis } from '@wordpress/theme';
import { Tooltip } from '@wordpress/ui';
import { useEffect } from 'react';
+import { OnboardingGuideProvider } from '@/components/onboarding-guide/use-onboarding-guide';
import { ConnectorProvider, queryClient } from '@/data/core';
import { AgentRunProvider } from '@/data/queries/use-agent-run';
import { useSyncAppUpdateStatus } from '@/data/queries/use-app-update';
@@ -42,7 +43,9 @@ function ThemedApp( { children }: PropsWithChildren ) {
}, [ colorScheme ] );
return (
- { children }
+
+ { children }
+
);
}
diff --git a/apps/ui/src/components/onboarding-guide/illustrations/chat.tsx b/apps/ui/src/components/onboarding-guide/illustrations/chat.tsx
new file mode 100644
index 0000000000..fbc41fff2e
--- /dev/null
+++ b/apps/ui/src/components/onboarding-guide/illustrations/chat.tsx
@@ -0,0 +1,154 @@
+import { __ } from '@wordpress/i18n';
+import { clsx } from 'clsx';
+import { at, easings, envelope, span, useTimeline } from './choreography';
+import { StreamingText } from './primitives';
+import styles from './style.module.css';
+
+function PlusGlyph() {
+ return (
+
+ );
+}
+
+function SendGlyph() {
+ return (
+
+ );
+}
+
+function FileGlyph() {
+ return (
+
+ );
+}
+
+const TYPE_MS = 42;
+const STREAM_MS = 17;
+
+// Page 2 (signed in) — Studio Code. A one-time playback: type a prompt, send it
+// (the composer slides off-stage and the prompt becomes a user bubble), stream
+// the reply, then show a tool call that keeps "reading" and a Replay control.
+// Everything derives from the timeline clock `t`; theme-adaptive.
+export function ChatIllustration() {
+ const prompt = __( 'Add a contact form to the homepage' );
+ const reply = __(
+ 'Good idea! I’ll open the homepage and check to see where the best place would be for a contact form.'
+ );
+
+ // Cue marks (ms), derived from the copy lengths so translations stay in sync.
+ const typeEnd = 550 + prompt.length * TYPE_MS;
+ const sentAt = typeEnd + 480;
+ const streamStart = sentAt + 680;
+ const streamEnd = streamStart + reply.length * STREAM_MS;
+ const toolAt = streamEnd + 300;
+ const replayAt = streamEnd + 800;
+
+ const { t, restart } = useTimeline( { duration: replayAt + 900, loop: false } );
+
+ const promptLen = Math.floor( span( t, 550, typeEnd, easings.linear ) * prompt.length );
+ const replyLen = Math.floor( span( t, streamStart, streamEnd, easings.linear ) * reply.length );
+ const sent = at( t, sentAt );
+ const typing = ! sent && promptLen > 0;
+ const showPlaceholder = sent || promptLen === 0;
+ const placeholder = sent
+ ? __( 'Queue the next message while I work…' )
+ : __( 'What can Studio build today?' );
+
+ // Composer: centered → slides down and off-stage on send.
+ const composerY = -54 + span( t, sentAt, sentAt + 800, easings.easeInOut ) * 186;
+ // Bubble: scales/fades up from the composer into place.
+ const bubbleP = span( t, sentAt, sentAt + 520, easings.easeOut );
+ const assistantOp = span( t, sentAt, sentAt + 300, easings.easeOut );
+ const toolOp = envelope( t, toolAt, 300 );
+ const replayOp = span( t, replayAt, replayAt + 300, easings.easeOut );
+
+ return (
+
+
+
+
+ { prompt }
+
+
+
+
+
+
+ { __( 'Reading' ) } front-page.php
+
+
+
+
+ { showPlaceholder ? (
+ { placeholder }
+ ) : null }
+ { typing ? (
+
+ ) : null }
+
+
+
+
+
+
+ { sent ? (
+
+
+
+ ) : null }
+ 0 ) && styles.sendButtonActive
+ ) }
+ >
+
+
+
+
+
+
+ );
+}
diff --git a/apps/ui/src/components/onboarding-guide/illustrations/choreography.ts b/apps/ui/src/components/onboarding-guide/illustrations/choreography.ts
new file mode 100644
index 0000000000..952f615dd9
--- /dev/null
+++ b/apps/ui/src/components/onboarding-guide/illustrations/choreography.ts
@@ -0,0 +1,168 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+// The choreography system for the onboarding illustrations. Every scene runs on
+// one clock: `useTimeline` exposes elapsed time `t` (ms), and the pure helpers
+// below turn `t` into styles. Loops and one-shots, reduced motion, and replay
+// are all handled here so scenes only declare *what* happens *when*.
+
+export type Easing = ( p: number ) => number;
+
+export const easings = {
+ linear: ( p: number ) => p,
+ easeIn: ( p: number ) => p * p * p,
+ easeOut: ( p: number ) => 1 - Math.pow( 1 - p, 3 ),
+ easeInOut: ( p: number ) => ( p < 0.5 ? 4 * p * p * p : 1 - Math.pow( -2 * p + 2, 3 ) / 2 ),
+};
+
+function prefersReducedMotion(): boolean {
+ return (
+ typeof window !== 'undefined' &&
+ Boolean( window.matchMedia?.( '(prefers-reduced-motion: reduce)' ).matches )
+ );
+}
+
+export interface Timeline {
+ /** Elapsed milliseconds: wraps at `duration` when looping, clamps otherwise. */
+ t: number;
+ /** Restart from zero (used by one-shot scenes' Replay control). */
+ restart: () => void;
+}
+
+// A single requestAnimationFrame clock. Reduced motion skips the animation and
+// rests on a representative frame — the start of a loop, or the end of a
+// one-shot (its finished state).
+export function useTimeline( {
+ duration,
+ loop = false,
+}: {
+ duration: number;
+ loop?: boolean;
+} ): Timeline {
+ const [ t, setT ] = useState( () => ( ! loop && prefersReducedMotion() ? duration : 0 ) );
+ const [ nonce, setNonce ] = useState( 0 );
+ const rafRef = useRef( 0 );
+
+ const restart = useCallback( () => setNonce( ( n ) => n + 1 ), [] );
+
+ useEffect( () => {
+ if ( prefersReducedMotion() ) {
+ setT( loop ? 0 : duration );
+ return;
+ }
+ let start = 0;
+ const tick = ( now: number ) => {
+ if ( ! start ) {
+ start = now;
+ }
+ const elapsed = now - start;
+ setT( loop ? elapsed % duration : Math.min( elapsed, duration ) );
+ if ( loop || elapsed < duration ) {
+ rafRef.current = requestAnimationFrame( tick );
+ }
+ };
+ rafRef.current = requestAnimationFrame( tick );
+ return () => cancelAnimationFrame( rafRef.current );
+ }, [ duration, loop, nonce ] );
+
+ return { t, restart };
+}
+
+// Has the clock passed a cue mark?
+export function at( t: number, mark: number ): boolean {
+ return t >= mark;
+}
+
+// Eased 0→1 progress across a window (before → 0, after → 1).
+export function span(
+ t: number,
+ from: number,
+ to: number,
+ easing: Easing = easings.easeInOut
+): number {
+ if ( t <= from ) {
+ return 0;
+ }
+ if ( t >= to ) {
+ return 1;
+ }
+ return easing( ( t - from ) / ( to - from ) );
+}
+
+// Opacity envelope: fade in over `inDur` at `inAt`, hold, then (optionally) fade
+// out over `outDur` at `outAt`. Returns 0→1.
+export function envelope(
+ t: number,
+ inAt: number,
+ inDur: number,
+ outAt = Infinity,
+ outDur = 0
+): number {
+ if ( t < inAt ) {
+ return 0;
+ }
+ if ( t < inAt + inDur ) {
+ return ( t - inAt ) / inDur;
+ }
+ if ( t < outAt ) {
+ return 1;
+ }
+ if ( t < outAt + outDur ) {
+ return 1 - ( t - outAt ) / outDur;
+ }
+ return 0;
+}
+
+export interface Keyframe {
+ /** Time (ms) of this keyframe. */
+ at: number;
+ /** Easing for the segment starting at this keyframe. */
+ ease?: Easing;
+ [ prop: string ]: number | Easing | undefined;
+}
+
+// Interpolates numeric properties across keyframes at time `t`. Any numeric key
+// present on the frames (x, y, scale, opacity, …) is tweened; the segment uses
+// the starting frame's `ease` (default easeInOut). Frames must be ordered by
+// `at`.
+export function sample( t: number, frames: Keyframe[] ): Record< string, number > {
+ const readNumbers = ( frame: Keyframe ): Record< string, number > => {
+ const out: Record< string, number > = {};
+ for ( const key of Object.keys( frame ) ) {
+ if ( key === 'at' || key === 'ease' ) {
+ continue;
+ }
+ const value = frame[ key ];
+ if ( typeof value === 'number' ) {
+ out[ key ] = value;
+ }
+ }
+ return out;
+ };
+
+ if ( t <= frames[ 0 ].at ) {
+ return readNumbers( frames[ 0 ] );
+ }
+ const last = frames[ frames.length - 1 ];
+ if ( t >= last.at ) {
+ return readNumbers( last );
+ }
+
+ let i = 0;
+ while ( i < frames.length - 1 && t > frames[ i + 1 ].at ) {
+ i++;
+ }
+ const a = frames[ i ];
+ const b = frames[ i + 1 ];
+ const ease = a.ease ?? easings.easeInOut;
+ const p = ease( ( t - a.at ) / ( b.at - a.at ) );
+
+ const from = readNumbers( a );
+ const to = readNumbers( b );
+ const out: Record< string, number > = {};
+ for ( const key of Object.keys( from ) ) {
+ const av = from[ key ];
+ const bv = to[ key ] ?? av;
+ out[ key ] = av + ( bv - av ) * p;
+ }
+ return out;
+}
diff --git a/apps/ui/src/components/onboarding-guide/illustrations/index.tsx b/apps/ui/src/components/onboarding-guide/illustrations/index.tsx
new file mode 100644
index 0000000000..2fcc12f684
--- /dev/null
+++ b/apps/ui/src/components/onboarding-guide/illustrations/index.tsx
@@ -0,0 +1,27 @@
+import { ChatIllustration } from './chat';
+import { Stage } from './primitives';
+import { SitesIllustration } from './sites';
+import styles from './style.module.css';
+import type { OrientationIllustrationId } from '@/data/onboarding/orientation-guide';
+import type { ComponentType } from 'react';
+
+// Each page's illustration is a self-contained animated scene registered by id.
+// Ids without a scene yet fall back to a plain tinted slot, so the guide always
+// renders while the remaining scenes are built.
+const SCENES: Partial< Record< OrientationIllustrationId, ComponentType > > = {
+ sites: SitesIllustration,
+ chat: ChatIllustration,
+};
+
+// Illustrations with a dark full-bleed background; the modal uses this to flip
+// the overlaid close (Skip) button to a light icon so it stays legible.
+const DARK_ILLUSTRATIONS: ReadonlySet< OrientationIllustrationId > = new Set( [ 'sites' ] );
+
+export function isDarkOrientationIllustration( id: OrientationIllustrationId ): boolean {
+ return DARK_ILLUSTRATIONS.has( id );
+}
+
+export function OrientationIllustration( { id }: { id: OrientationIllustrationId } ) {
+ const Scene = SCENES[ id ];
+ return { Scene ? : };
+}
diff --git a/apps/ui/src/components/onboarding-guide/illustrations/primitives.tsx b/apps/ui/src/components/onboarding-guide/illustrations/primitives.tsx
new file mode 100644
index 0000000000..e45b355f0f
--- /dev/null
+++ b/apps/ui/src/components/onboarding-guide/illustrations/primitives.tsx
@@ -0,0 +1,85 @@
+import { clsx } from 'clsx';
+import styles from './style.module.css';
+import type { OrientationIllustrationId } from '@/data/onboarding/orientation-guide';
+import type { CSSProperties, ReactNode } from 'react';
+
+// Shared building blocks for the illustration scenes. Motion is never baked in
+// here — each primitive takes a `style` the scene computes from the timeline, so
+// the same pieces compose into any choreography.
+
+// The full-bleed header slot every illustration fills: a fixed aspect ratio so
+// the popup header keeps a stable height, and a positioning context for the
+// scene. Each scene owns its own background and (if needed) color-scheme scope.
+export function Stage( { id, children }: { id: OrientationIllustrationId; children: ReactNode } ) {
+ return (
+
+ { children }
+
+ );
+}
+
+// A pointer used to demonstrate interactions. Position/scale/opacity come from
+// the scene via `style`. The macOS look — black fill, thick rounded white
+// border — reads clearly on any background.
+export function Cursor( { className, style }: { className?: string; style?: CSSProperties } ) {
+ return (
+
+ );
+}
+
+// A tooltip pill. The scene controls visibility/lift through `style` (opacity +
+// transform) and placement through `className`.
+export function Tooltip( {
+ className,
+ style,
+ children,
+}: {
+ className?: string;
+ style?: CSSProperties;
+ children: ReactNode;
+} ) {
+ return (
+
+ { children }
+
+ );
+}
+
+// Renders the first `count` characters of `text`, with a blinking caret while
+// still streaming. Used for both the typed prompt and the streamed reply.
+export function StreamingText( {
+ text,
+ count,
+ className,
+ caretClassName,
+}: {
+ text: string;
+ count: number;
+ className?: string;
+ caretClassName?: string;
+} ) {
+ return (
+
+ { text.slice( 0, count ) }
+ { count < text.length ? : null }
+
+ );
+}
diff --git a/apps/ui/src/components/onboarding-guide/illustrations/sites.tsx b/apps/ui/src/components/onboarding-guide/illustrations/sites.tsx
new file mode 100644
index 0000000000..15367e3941
--- /dev/null
+++ b/apps/ui/src/components/onboarding-guide/illustrations/sites.tsx
@@ -0,0 +1,170 @@
+import { __ } from '@wordpress/i18n';
+import { privateApis } from '@wordpress/theme';
+import { clsx } from 'clsx';
+import { useColorScheme } from '@/hooks/use-color-scheme';
+import { unlock } from '@/lock-unlock';
+import { easings, envelope, sample, useTimeline, type Keyframe } from './choreography';
+import { Cursor, Tooltip } from './primitives';
+import styles from './style.module.css';
+import type { CSSProperties } from 'react';
+
+const { ThemeProvider } = unlock( privateApis );
+
+// The sidebar sits on the dark window chrome in both color schemes, so this
+// scene mirrors the sidebar-layout: a dark chrome background and a nested dark
+// theme scope so the row's wpds tokens resolve against the dark ramp.
+const CHROME_BG_LIGHT = '#1e1e1e';
+const CHROME_BG_DARK = '#161616';
+
+const LOOP = 11000;
+
+// The pointer's path (offsets from the row centre): drift in → hover the
+// overview button → move to the status button → press → hold while it starts →
+// leave. Per-segment easing gives it a human cadence.
+const CURSOR_PATH: Keyframe[] = [
+ { at: 0, x: 168, y: 96, scale: 1, opacity: 0, ease: easings.easeOut },
+ { at: 1100, x: 82, y: 8, scale: 1, opacity: 1, ease: easings.linear },
+ { at: 3080, x: 82, y: 8, scale: 1, opacity: 1, ease: easings.easeInOut },
+ { at: 4070, x: 108, y: 8, scale: 1, opacity: 1, ease: easings.easeIn },
+ { at: 4840, x: 108, y: 8, scale: 1, opacity: 1, ease: easings.easeIn },
+ { at: 5005, x: 108, y: 9, scale: 0.86, opacity: 1, ease: easings.easeOut },
+ { at: 5170, x: 108, y: 8, scale: 1, opacity: 1, ease: easings.linear },
+ { at: 8470, x: 108, y: 8, scale: 1, opacity: 1, ease: easings.easeIn },
+ { at: 9460, x: 170, y: 98, scale: 1, opacity: 0 },
+ { at: LOOP, x: 170, y: 98, scale: 1, opacity: 0 },
+];
+
+// Play triangle: shown while stopped, gone once the site starts, back in during
+// the closing pause so the loop resets cleanly.
+function playOpacity( t: number ): number {
+ if ( t < 4840 ) {
+ return 1;
+ }
+ if ( t < 5170 ) {
+ return 1 - ( t - 4840 ) / 330;
+ }
+ if ( t < 10300 ) {
+ return 0;
+ }
+ if ( t < 10800 ) {
+ return ( t - 10300 ) / 500;
+ }
+ return 1;
+}
+
+// Status dot: fades in amber (starting, with a gentle pulse), switches to green
+// (running), then fades out for the reset.
+function statusDot( t: number ): { opacity: number; color: string } {
+ let opacity = envelope( t, 4840, 330, 9900, 550 );
+ if ( t > 5170 && t < 6820 ) {
+ const phase = ( ( t - 5170 ) / 900 ) % 1;
+ opacity *= 0.4 + 0.6 * Math.abs( 1 - 2 * phase );
+ }
+ const color =
+ t < 7040 ? 'var(--studio-color-status-transitioning)' : 'var(--studio-color-status-running)';
+ return { opacity, color };
+}
+
+function tooltipStyle( opacity: number ): CSSProperties {
+ return { opacity, transform: `translateX(-50%) translateY(${ ( 1 - opacity ) * 3 }px)` };
+}
+
+function hoverStyle( opacity: number ): CSSProperties {
+ return { backgroundColor: `rgba(255, 255, 255, ${ ( 0.09 * opacity ).toFixed( 3 ) })` };
+}
+
+function SettingsGlyph() {
+ return (
+
+ );
+}
+
+// Page 1 — the sidebar. A single site row plays out what the copy points at: a
+// cursor drifts in, hovers the site-overview button (tooltip), moves to the
+// status button and clicks it to start the site — stopped (play) → starting
+// (amber) → running (green) — then leaves and the loop repeats.
+export function SitesIllustration() {
+ const colorScheme = useColorScheme();
+ const chromeBg = colorScheme === 'dark' ? CHROME_BG_DARK : CHROME_BG_LIGHT;
+ const { t } = useTimeline( { duration: LOOP, loop: true } );
+
+ const cursor = sample( t, CURSOR_PATH );
+ const dot = statusDot( t );
+ const overviewTip = envelope( t, 770, 440, 2970, 440 );
+ const stoppedTip = envelope( t, 3740, 440, 4840, 330 );
+ const startingTip = envelope( t, 4840, 330, 6820, 330 );
+ const runningTip = envelope( t, 6820, 330, 8580, 440 );
+
+ return (
+
+
+
+
+ Photography Portfolio
+
+
+ Marketing Site
+
+
+
My WordPress Website
+
+
+
+ { __( 'Site overview' ) }
+
+
+
+
+ { /* Three tooltips fade one into the next, reading as one box whose
+ wording changes as the site starts. */ }
+
+ { __( 'Site status: Stopped' ) }
+
+
{ __( 'Starting site…' ) }
+
+ { __( 'Site status: Running' ) }
+
+
+
+
+
+
+ Recipe Blog
+
+
+ Dev Sandbox
+
+
+
+
+ );
+}
diff --git a/apps/ui/src/components/onboarding-guide/illustrations/style.module.css b/apps/ui/src/components/onboarding-guide/illustrations/style.module.css
new file mode 100644
index 0000000000..dc82ab664f
--- /dev/null
+++ b/apps/ui/src/components/onboarding-guide/illustrations/style.module.css
@@ -0,0 +1,421 @@
+/* ---- Shared stage ----
+ Motion for the scenes is driven in JS by the choreography timeline; this file
+ only holds static styling and two tiny local effects (caret blink, tool
+ shimmer). */
+
+/* The full-bleed header slot. A fixed aspect ratio gives the popup header a
+ stable height that can't collapse while the dialog animates in; overflow is
+ visible so tooltips can extend past the edge. */
+.stage {
+ position: relative;
+ display: grid;
+ place-items: center;
+ width: 100%;
+ aspect-ratio: 320 / 150;
+ flex-shrink: 0;
+ overflow: visible;
+ border-bottom: var(--wpds-border-width-xs) solid var(--wpds-color-stroke-surface-neutral-weak);
+}
+
+/* Fallback for ids without a built scene yet. */
+.placeholder {
+ position: absolute;
+ inset: 0;
+ background: color-mix(in srgb, var(--wpds-color-fg-interactive-brand) 8%, transparent);
+}
+
+/* ---- Sites scene ---- */
+
+.sitesScene {
+ position: absolute;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ background: var(--sites-chrome-bg);
+}
+
+/* A stack of rows so the focus row reads as one entry in a real sidebar list.
+ No container mask — a mask would clip the focus row's tooltips at the list's
+ edge; the neighbours recede via per-row opacity instead. */
+.list {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ width: 264px;
+}
+
+/* Quiet, non-interactive neighbours; the outer ones fade further back. */
+.rowGhost {
+ box-sizing: border-box;
+ display: flex;
+ align-items: center;
+ height: 34px;
+ padding: 0 var(--wpds-dimension-padding-sm) 0 var(--wpds-dimension-padding-md);
+ border-radius: 6px;
+ opacity: 0.7;
+}
+
+.rowGhostFar {
+ opacity: 0.4;
+}
+
+.rowGhostName {
+ overflow: hidden;
+ font-size: var(--wpds-typography-font-size-md);
+ line-height: 1.2;
+ color: var(--wpds-color-fg-content-neutral-weak);
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+.row {
+ position: relative;
+ box-sizing: border-box;
+ display: flex;
+ align-items: center;
+ gap: var(--wpds-dimension-padding-xs);
+ width: 100%;
+ height: 34px;
+ padding: 0 var(--wpds-dimension-padding-sm) 0 var(--wpds-dimension-padding-md);
+ border-radius: 6px;
+ background: var(--wpds-color-bg-interactive-neutral-weak-active);
+}
+
+.rowName {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ font-size: var(--wpds-typography-font-size-md);
+ line-height: 1.2;
+ color: var(--wpds-color-fg-content-neutral);
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+.rowActions {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ flex-shrink: 0;
+}
+
+/* The scene tints the background (hover) inline via the timeline. */
+.btn {
+ position: relative;
+ display: grid;
+ place-items: center;
+ width: 24px;
+ height: 24px;
+ border-radius: 4px;
+ color: var(--wpds-color-fg-content-neutral);
+}
+
+.btnGlyph {
+ width: 18px;
+ height: 18px;
+ fill: currentColor;
+}
+
+/* The status glyphs stack in the button's single grid cell so they can
+ crossfade between states (play → starting dot → running dot). */
+.statusPlay,
+.statusDot {
+ grid-area: 1 / 1;
+}
+
+.statusPlay {
+ width: 9px;
+ height: 9px;
+ fill: var(--wpds-color-fg-content-neutral);
+}
+
+.statusDot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+}
+
+/* Tooltip pill. Visibility/lift come from the scene inline; positioning and
+ look live here. Centered over the button. */
+.tooltip {
+ position: absolute;
+ bottom: calc(100% + 7px);
+ left: 50%;
+ padding: 3px 8px;
+ border-radius: 4px;
+ background: #fff;
+ color: #1e1e1e;
+ font-size: 11px;
+ font-weight: var(--wpds-typography-font-weight-medium);
+ line-height: 1.4;
+ white-space: nowrap;
+ box-shadow: 0 2px 8px rgb(0 0 0 / 24%);
+ pointer-events: none;
+}
+
+.cursor {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform-origin: 4px 3px;
+ filter: drop-shadow(0 2px 3px rgb(0 0 0 / 30%));
+ will-change: transform, opacity;
+}
+
+/* ---- Chat scene ----
+ Theme-adaptive (the chat pane follows light/dark). */
+
+.chatScene {
+ position: absolute;
+ inset: 0;
+ box-sizing: border-box;
+ overflow: hidden;
+ background: var(--wpds-color-bg-surface-neutral-weak);
+}
+
+/* Above the resting composer, with room up top so the first bubble clears the
+ modal's Skip button. */
+.chatConversation {
+ position: absolute;
+ top: 46px;
+ right: 26px;
+ bottom: 20px;
+ left: 26px;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ overflow: hidden;
+}
+
+.userBubble {
+ align-self: flex-end;
+ max-width: 82%;
+ padding: 5px 11px;
+ border-radius: 14px 14px 6px 14px;
+ background: color-mix(in srgb, var(--wpds-color-fg-interactive-brand, #2563eb) 12%, transparent);
+ color: var(--wpds-color-fg-content-neutral);
+ font-size: var(--wpds-typography-font-size-sm);
+ line-height: 1.4;
+ transform-origin: bottom right;
+}
+
+.assistantText {
+ color: var(--wpds-color-fg-content-neutral);
+ font-size: var(--wpds-typography-font-size-sm);
+ line-height: 1.45;
+}
+
+/* Blinking cursor at the end of the streaming reply. */
+.streamCaret {
+ display: inline-block;
+ width: 6px;
+ height: 13px;
+ margin-left: 2px;
+ vertical-align: -2px;
+ background: var(--wpds-color-fg-content-neutral-weak);
+ animation: chatCaretBlink 1s steps(1) infinite;
+}
+
+.toolChip {
+ align-self: flex-start;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ color: var(--wpds-color-fg-content-neutral-weak);
+ font-size: var(--wpds-typography-font-size-xs);
+}
+
+.toolIcon {
+ width: 14px;
+ height: 14px;
+ flex-shrink: 0;
+}
+
+/* A light band sweeps across the label to signal the tool is working. */
+.toolShimmer {
+ background: linear-gradient(
+ 100deg,
+ var(--wpds-color-fg-content-neutral-weak) 35%,
+ var(--wpds-color-fg-content-neutral) 50%,
+ var(--wpds-color-fg-content-neutral-weak) 65%
+ );
+ background-size: 220% 100%;
+ background-clip: text;
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ color: transparent;
+ animation: toolShimmer 1.6s linear infinite;
+}
+
+@keyframes toolShimmer {
+ from {
+ background-position: 200% 0;
+ }
+ to {
+ background-position: -200% 0;
+ }
+}
+
+/* Subtle text control at the modal's top-left, shown once the playback ends.
+ Opacity/pointer-events are set inline by the scene. */
+.replayButton {
+ position: absolute;
+ top: 10px;
+ left: 14px;
+ z-index: 2;
+ padding: 2px 4px;
+ border: 0;
+ background: transparent;
+ color: var(--wpds-color-fg-content-neutral-weak);
+ font-size: var(--wpds-typography-font-size-xs);
+ font-weight: var(--wpds-typography-font-weight-medium);
+ cursor: var(--wpds-cursor-control, pointer);
+ transition: color 120ms ease;
+}
+
+.replayButton:hover {
+ color: var(--wpds-color-fg-content-neutral);
+}
+
+.composer {
+ position: absolute;
+ right: 26px;
+ bottom: 12px;
+ left: 26px;
+ box-sizing: border-box;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding: 8px 10px 7px;
+ border-radius: 8px 8px 20px 8px;
+ background: var(--wpds-color-bg-surface-neutral-strong);
+ outline: 1.5px solid var(--wpds-color-fg-interactive-brand, #2563eb);
+ will-change: transform;
+}
+
+.composerInput {
+ position: relative;
+ min-height: 17px;
+}
+
+.composerPlaceholder {
+ position: absolute;
+ inset: 0;
+ color: var(--wpds-color-fg-content-neutral-weak);
+ font-size: var(--wpds-typography-font-size-sm);
+ line-height: 17px;
+ white-space: nowrap;
+}
+
+.composerTyped {
+ position: absolute;
+ inset: 0;
+ display: inline-flex;
+ align-items: center;
+ overflow: hidden;
+ color: var(--wpds-color-fg-content-neutral);
+ font-size: var(--wpds-typography-font-size-sm);
+ line-height: 17px;
+ white-space: nowrap;
+}
+
+.composerCaret {
+ flex-shrink: 0;
+ width: 1.5px;
+ height: 13px;
+ margin-left: 1px;
+ background: var(--wpds-color-fg-interactive-brand, #2563eb);
+ animation: chatCaretBlink 1s steps(1) infinite;
+}
+
+.composerBar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.composerButtons {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+/* Black stop button (fg-content-neutral) with a light square, matching the
+ real composer's busy state; shown alongside the send button. */
+.stopButton {
+ display: grid;
+ place-items: center;
+ width: 22px;
+ height: 22px;
+ border-radius: 999px;
+ background: var(--wpds-color-fg-content-neutral);
+ color: var(--wpds-color-bg-surface-neutral-strong);
+}
+
+.stopGlyph {
+ width: 8px;
+ height: 8px;
+ border-radius: 1px;
+ background: currentColor;
+}
+
+.plusButton {
+ display: grid;
+ place-items: center;
+ width: 18px;
+ height: 18px;
+ color: var(--wpds-color-fg-content-neutral-weak);
+}
+
+.plusButton svg {
+ width: 16px;
+ height: 16px;
+}
+
+.sendButton {
+ display: grid;
+ place-items: center;
+ width: 22px;
+ height: 22px;
+ border-radius: 999px;
+ background: color-mix(in srgb, var(--wpds-color-fg-content-neutral) 14%, transparent);
+ color: var(--wpds-color-fg-content-neutral-weak);
+ transition: background 150ms ease, color 150ms ease;
+}
+
+.sendButton svg {
+ width: 15px;
+ height: 15px;
+}
+
+.sendButtonActive {
+ background: var(--wpds-color-fg-interactive-brand, #2563eb);
+ color: #fff;
+}
+
+@keyframes chatCaretBlink {
+ 0%,
+ 49% {
+ opacity: 1;
+ }
+ 50%,
+ 100% {
+ opacity: 0;
+ }
+}
+
+/* The choreography timeline already rests on a static frame under reduced
+ motion; only the small looping CSS effects need silencing. */
+@media (prefers-reduced-motion: reduce) {
+ .streamCaret,
+ .composerCaret {
+ animation: none;
+ }
+
+ .toolShimmer {
+ animation: none;
+ background: none;
+ color: var(--wpds-color-fg-content-neutral-weak);
+ -webkit-text-fill-color: var(--wpds-color-fg-content-neutral-weak);
+ }
+}
diff --git a/apps/ui/src/components/onboarding-guide/index.tsx b/apps/ui/src/components/onboarding-guide/index.tsx
new file mode 100644
index 0000000000..7a57a4dd1c
--- /dev/null
+++ b/apps/ui/src/components/onboarding-guide/index.tsx
@@ -0,0 +1,90 @@
+import { __ } from '@wordpress/i18n';
+import { Button, Dialog } from '@wordpress/ui';
+import { clsx } from 'clsx';
+import { useState } from 'react';
+import { OrientationIllustration, isDarkOrientationIllustration } from './illustrations';
+import styles from './style.module.css';
+import type { GuideDefinition } from '@/data/onboarding/orientation-guide';
+
+interface OnboardingGuideProps {
+ guide: GuideDefinition;
+ onComplete: () => void;
+ onDismiss: () => void;
+}
+
+// A focused, paged orientation modal in the spirit of Gutenberg's Guide:
+// full-bleed illustration on top, copy in the middle, a dot pager and
+// Back/Next at the bottom. It demands attention — a backdrop, no click-outside
+// dismissal — so the welcome isn't lost with a stray click.
+export function OnboardingGuide( { guide, onComplete, onDismiss }: OnboardingGuideProps ) {
+ const [ pageIndex, setPageIndex ] = useState( 0 );
+ const page = guide.pages[ pageIndex ];
+ const isFirst = pageIndex === 0;
+ const isLast = pageIndex === guide.pages.length - 1;
+
+ const goNext = () => {
+ if ( isLast ) {
+ onComplete();
+ } else {
+ setPageIndex( ( index ) => index + 1 );
+ }
+ };
+ const goBack = () => setPageIndex( ( index ) => Math.max( 0, index - 1 ) );
+
+ return (
+ {
+ if ( open ) {
+ return;
+ }
+ if ( details.reason === 'escape-key' || details.reason === 'close-press' ) {
+ onDismiss();
+ }
+ } }
+ >
+
+
+
+
+ { page.title() }
+
+ { page.description() }
+
+
+
+
+ { ! isFirst ? (
+
+ ) : null }
+
+
+ { guide.pages.map( ( _, index ) => (
+
+ ) ) }
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/ui/src/components/onboarding-guide/style.module.css b/apps/ui/src/components/onboarding-guide/style.module.css
new file mode 100644
index 0000000000..3b27201891
--- /dev/null
+++ b/apps/ui/src/components/onboarding-guide/style.module.css
@@ -0,0 +1,116 @@
+/* Width only. The wpds dialog popup owns its own positioning
+ (position: fixed + translate centering) and clipping inside
+ @layer wp-ui-components — this module's classes are unlayered and would
+ silently clobber any property repeated here, which is exactly how an
+ earlier `position: relative` dropped the dialog into document flow and
+ broke both its centering and the app layout behind it. Add properties
+ only after checking them against the base popup styles. */
+.popup {
+ width: 380px;
+ max-width: calc(100vw - 32px);
+}
+
+.close {
+ position: absolute;
+ top: var(--wpds-dimension-padding-sm);
+ inset-inline-end: var(--wpds-dimension-padding-sm);
+ z-index: 1;
+}
+
+/* Over a dark full-bleed illustration the default (dark) icon vanishes; force a
+ light icon so Skip stays legible. */
+.closeOnDark,
+.closeOnDark svg {
+ color: #fff;
+ fill: currentColor;
+}
+
+/* The modal backdrop covers the window's usual drag region, so nothing is
+ movable while the tour is open. The popup itself can't be a drag handle —
+ it's centered with a CSS transform, and Chromium ignores -webkit-app-region
+ under a transformed ancestor — but the backdrop isn't transformed, so make it
+ the drag surface. Scoped to this guide's backdrop (the sibling preceding our
+ popup) so every other dialog keeps its click-outside-to-dismiss. */
+:global(
+ [data-testid='dialog-backdrop']:has(~ [data-orientation-guide], ~ * [data-orientation-guide])
+) {
+ -webkit-app-region: drag;
+}
+
+.content {
+ display: flex;
+ flex-direction: column;
+ gap: var(--wpds-dimension-gap-sm);
+ /* Sized to the tallest page (a title over a three-line description) so the
+ modal height stays constant as the copy changes between pages. */
+ min-height: 132px;
+ box-sizing: border-box;
+ padding: var(--wpds-dimension-padding-xl) var(--wpds-dimension-padding-xl)
+ var(--wpds-dimension-padding-lg);
+ text-align: start;
+}
+
+.title {
+ margin: 0;
+ font-size: var(--wpds-typography-font-size-lg);
+ font-weight: var(--wpds-typography-font-weight-medium);
+ line-height: 1.3;
+ text-wrap: balance;
+}
+
+.description {
+ margin: 0;
+ color: var(--wpds-color-fg-content-neutral-weak);
+ font-size: var(--wpds-typography-font-size-md);
+ line-height: 1.5;
+ text-wrap: pretty;
+}
+
+/* Three zones: Back on the left, the pager centered, the primary action on the
+ right. The 1fr side columns keep the pager centered even when Back is absent
+ (first page). */
+.footer {
+ display: grid;
+ grid-template-columns: 1fr auto 1fr;
+ align-items: center;
+ gap: var(--wpds-dimension-gap-md);
+ padding: var(--wpds-dimension-padding-md) var(--wpds-dimension-padding-xl)
+ var(--wpds-dimension-padding-xl);
+}
+
+.footerStart {
+ justify-self: start;
+}
+
+.footerEnd {
+ justify-self: end;
+}
+
+.pager {
+ justify-self: center;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.dot {
+ width: 6px;
+ height: 6px;
+ /* Fixed radius (not 50%) so it stays constant as the active dot stretches
+ to a pill — a percentage radius warps mid-transition. At 6px this still
+ renders a full circle. */
+ border-radius: 3px;
+ background: color-mix(in srgb, var(--wpds-color-fg-content-neutral) 22%, transparent);
+ transition: background 160ms ease, width 160ms ease;
+}
+
+.dotActive {
+ width: 18px;
+ background: var(--wpds-color-fg-interactive-brand);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .dot {
+ transition: none;
+ }
+}
diff --git a/apps/ui/src/components/onboarding-guide/use-onboarding-guide.tsx b/apps/ui/src/components/onboarding-guide/use-onboarding-guide.tsx
new file mode 100644
index 0000000000..0fad352abe
--- /dev/null
+++ b/apps/ui/src/components/onboarding-guide/use-onboarding-guide.tsx
@@ -0,0 +1,64 @@
+import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react';
+import { getOrientationGuide } from '@/data/onboarding/orientation-guide';
+import { OnboardingGuide } from './index';
+import type { OrientationVariant } from '@/data/onboarding/orientation-guide';
+import type { ReactNode } from 'react';
+
+export type GuideEndReason = 'completed' | 'dismissed';
+
+interface OpenGuideOptions {
+ onEnd?: ( reason: GuideEndReason ) => void;
+}
+
+interface OnboardingGuideApi {
+ isOpen: boolean;
+ openGuide( variant: OrientationVariant, options?: OpenGuideOptions ): void;
+ close( reason: GuideEndReason ): void;
+}
+
+export type OpenGuide = OnboardingGuideApi[ 'openGuide' ];
+
+const OnboardingGuideContext = createContext< OnboardingGuideApi | null >( null );
+
+export function OnboardingGuideProvider( { children }: { children: ReactNode } ) {
+ const [ variant, setVariant ] = useState< OrientationVariant | null >( null );
+ const onEndRef = useRef< ( ( reason: GuideEndReason ) => void ) | null >( null );
+
+ const openGuide = useCallback( ( next: OrientationVariant, options?: OpenGuideOptions ) => {
+ onEndRef.current = options?.onEnd ?? null;
+ setVariant( next );
+ }, [] );
+
+ const close = useCallback( ( reason: GuideEndReason ) => {
+ const callback = onEndRef.current;
+ onEndRef.current = null;
+ setVariant( null );
+ callback?.( reason );
+ }, [] );
+
+ const api = useMemo< OnboardingGuideApi >(
+ () => ( { isOpen: variant !== null, openGuide, close } ),
+ [ variant, openGuide, close ]
+ );
+
+ return (
+
+ { children }
+ { variant ? (
+ close( 'completed' ) }
+ onDismiss={ () => close( 'dismissed' ) }
+ />
+ ) : null }
+
+ );
+}
+
+export function useOnboardingGuide(): OnboardingGuideApi {
+ const api = useContext( OnboardingGuideContext );
+ if ( ! api ) {
+ throw new Error( 'useOnboardingGuide must be used within an OnboardingGuideProvider' );
+ }
+ return api;
+}
diff --git a/apps/ui/src/data/core/connectors/browser-onboarding-hints.ts b/apps/ui/src/data/core/connectors/browser-onboarding-hints.ts
new file mode 100644
index 0000000000..5775e48182
--- /dev/null
+++ b/apps/ui/src/data/core/connectors/browser-onboarding-hints.ts
@@ -0,0 +1,21 @@
+import type { OnboardingHintsState } from '../types';
+
+// Workbench onboarding state. The desktop persists this in appdata via IPC; the
+// browser connectors (local + hosted) have no such store, so it lives in
+// localStorage, per origin.
+const ONBOARDING_HINTS_STORAGE_KEY = 'studio-onboarding-hints';
+
+export function readOnboardingHints(): OnboardingHintsState {
+ try {
+ const raw = window.localStorage.getItem( ONBOARDING_HINTS_STORAGE_KEY );
+ const parsed: unknown = raw ? JSON.parse( raw ) : {};
+ return parsed && typeof parsed === 'object' ? ( parsed as OnboardingHintsState ) : {};
+ } catch {
+ return {};
+ }
+}
+
+export function writeOnboardingHints( partial: Partial< OnboardingHintsState > ): void {
+ const merged: OnboardingHintsState = { ...readOnboardingHints(), ...partial };
+ window.localStorage.setItem( ONBOARDING_HINTS_STORAGE_KEY, JSON.stringify( merged ) );
+}
diff --git a/apps/ui/src/data/core/connectors/hosted/index.ts b/apps/ui/src/data/core/connectors/hosted/index.ts
index 976b3de374..d6cd8aa699 100644
--- a/apps/ui/src/data/core/connectors/hosted/index.ts
+++ b/apps/ui/src/data/core/connectors/hosted/index.ts
@@ -1,5 +1,6 @@
import { fetchWordPressVersions } from '@studio/common/lib/wordpress-versions';
import { __ } from '@wordpress/i18n';
+import { readOnboardingHints, writeOnboardingHints } from '../browser-onboarding-hints';
import { applyStoredSiteOrder, storeSiteOrder } from '../browser-site-order';
import { UnsupportedError } from '../unsupported-error';
import { readWapuuScore, writeWapuuScore } from '../wapuu-score-storage';
@@ -463,6 +464,16 @@ export function createHostedConnector( { apiBaseUrl }: HostedConnectorOptions ):
async disableAgenticUi() {
// No-op in the browser.
},
+ async getOnboardingHints() {
+ return readOnboardingHints();
+ },
+ async setOnboardingHints( partial ) {
+ writeOnboardingHints( partial );
+ },
+ onShowGettingStarted() {
+ // No application menu on the hosted surface.
+ return () => {};
+ },
async getAppUpdateStatus() {
return { readyToInstall: false, version: null };
},
diff --git a/apps/ui/src/data/core/connectors/ipc/index.ts b/apps/ui/src/data/core/connectors/ipc/index.ts
index 53ec379208..f502db6d27 100644
--- a/apps/ui/src/data/core/connectors/ipc/index.ts
+++ b/apps/ui/src/data/core/connectors/ipc/index.ts
@@ -920,6 +920,20 @@ export function createIpcConnector(): Connector {
await ipcApi.disableAgenticUi();
},
+ async getOnboardingHints() {
+ return ipcApi.getOnboardingHints();
+ },
+
+ async setOnboardingHints( partial ) {
+ await ipcApi.saveOnboardingHints( partial );
+ },
+
+ onShowGettingStarted( listener ) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const ipcListener = ( window as any ).ipcListener;
+ return ipcListener.subscribe( 'show-getting-started', () => listener() );
+ },
+
async getAppUpdateStatus() {
return ipcApi.getAppUpdateStatus();
},
diff --git a/apps/ui/src/data/core/connectors/local/index.ts b/apps/ui/src/data/core/connectors/local/index.ts
index f9f29b7f5b..4c260bc7e9 100644
--- a/apps/ui/src/data/core/connectors/local/index.ts
+++ b/apps/ui/src/data/core/connectors/local/index.ts
@@ -1,6 +1,7 @@
import { getAuthenticationUrl, getSignUpUrl } from '@studio/common/lib/oauth';
import { fetchWordPressVersions } from '@studio/common/lib/wordpress-versions';
import { __ } from '@wordpress/i18n';
+import { readOnboardingHints, writeOnboardingHints } from '../browser-onboarding-hints';
import { applyStoredSiteOrder, storeSiteOrder } from '../browser-site-order';
import { buildPublishCheckoutUrl } from '../publish-checkout-url';
import { UnsupportedError } from '../unsupported-error';
@@ -846,6 +847,16 @@ export function createLocalConnector( { apiBaseUrl }: LocalConnectorOptions ): C
async disableAgenticUi() {
// No-op in the browser.
},
+ async getOnboardingHints() {
+ return readOnboardingHints();
+ },
+ async setOnboardingHints( partial ) {
+ writeOnboardingHints( partial );
+ },
+ onShowGettingStarted() {
+ // No application menu in a browser tab.
+ return () => {};
+ },
async getAppUpdateStatus() {
return { readyToInstall: false, version: null };
},
diff --git a/apps/ui/src/data/core/index.ts b/apps/ui/src/data/core/index.ts
index d787848ece..356915c981 100644
--- a/apps/ui/src/data/core/index.ts
+++ b/apps/ui/src/data/core/index.ts
@@ -13,6 +13,7 @@ export type {
InstalledApps,
LocalMediaFile,
LoadedAiSession,
+ OnboardingHintsState,
ProposedSitePath,
PullSiteProgress,
QuitSitesBehavior,
diff --git a/apps/ui/src/data/core/types.ts b/apps/ui/src/data/core/types.ts
index 9d4ee6eff3..1a41154bc2 100644
--- a/apps/ui/src/data/core/types.ts
+++ b/apps/ui/src/data/core/types.ts
@@ -446,6 +446,16 @@ export interface Connector {
// Switches back to the legacy (classic) Studio UI.
disableAgenticUi(): Promise< void >;
+ // Agentic UI onboarding state. Distinct from getOnboardingCompleted (the
+ // pre-workbench first-run welcome flag). setOnboardingHints shallow-merges
+ // its partial. Desktop persists to app.json; hosted/web to localStorage.
+ getOnboardingHints(): Promise< OnboardingHintsState >;
+ setOnboardingHints( partial: Partial< OnboardingHintsState > ): Promise< void >;
+
+ // Fires when the user picks Help ▸ Getting Started in the application menu
+ // (desktop only). No-ops where there's no OS menu.
+ onShowGettingStarted( listener: () => void ): () => void;
+
// Auto-updater status.
getAppUpdateStatus(): Promise< AppUpdateStatus >;
installAppUpdate(): Promise< void >;
@@ -457,6 +467,19 @@ export interface AppUpdateStatus {
version: string | null;
}
+// Persisted first-run onboarding state for the workbench. Separate from the
+// pre-workbench welcome flag (getOnboardingCompleted).
+export interface OnboardingHintsState {
+ // Version of the orientation guide the user finished or explicitly skipped.
+ tourCompletedVersion?: number;
+ // Version of the orientation guide the user closed early (Esc / Skip).
+ tourDismissedVersion?: number;
+ // True when the user reached the agentic workbench by opting in from classic
+ // Studio (vs a fresh install that starts here). Drives the guide's first-page
+ // "Welcome to WordPress Studio 2.0" migrating copy.
+ migratedFromClassic?: boolean;
+}
+
export interface SnapshotUsage {
siteCount: number;
siteLimit: number;
diff --git a/apps/ui/src/data/onboarding/orientation-guide.ts b/apps/ui/src/data/onboarding/orientation-guide.ts
new file mode 100644
index 0000000000..5cd8df5b01
--- /dev/null
+++ b/apps/ui/src/data/onboarding/orientation-guide.ts
@@ -0,0 +1,117 @@
+import { __ } from '@wordpress/i18n';
+
+// Bump to re-show the orientation guide to everyone who saw the previous
+// version (compared against OnboardingHintsState.tourCompletedVersion /
+// tourDismissedVersion — the field names predate the modal and stay generic).
+export const ORIENTATION_GUIDE_VERSION = 2;
+
+// Two independent axes drive the copy (see the Welcome Tour Figma):
+// migrating — coming from classic Studio vs a fresh install (page 1 only)
+// chatEnabled — whether Studio Code (chat) is on offer (pages 2 and 3). The
+// design labels this "signed in", but signed-out, offline, and
+// opted-out (Settings → AI) all collapse to the non-chat copy.
+export interface OrientationVariant {
+ migrating: boolean;
+ chatEnabled: boolean;
+}
+
+export type OrientationIllustrationId = 'sites' | 'chat' | 'preview' | 'overview';
+
+export interface GuidePage {
+ illustration: OrientationIllustrationId;
+ title: () => string;
+ description: () => string;
+ // The advance button's label.
+ action: () => string;
+}
+
+export interface GuideDefinition {
+ pages: GuidePage[];
+}
+
+// Page 1 — the sidebar. Differs only by new vs migrating; a migrating user gets
+// reassured their existing sites carried over.
+function sitesPage( migrating: boolean ): GuidePage {
+ if ( migrating ) {
+ return {
+ illustration: 'sites',
+ title: () => __( 'Welcome to WordPress Studio 2.0' ),
+ description: () =>
+ __(
+ 'Everything you built in Studio is right here in the sidebar. Same sites, same files with a new workbench around them.'
+ ),
+ action: () => __( 'Next' ),
+ };
+ }
+ return {
+ illustration: 'sites',
+ title: () => __( 'Welcome to WordPress Studio' ),
+ description: () =>
+ /* translators: "on the left" describes the sidebar in left-to-right layouts; in right-to-left languages it sits on the right — adapt the direction accordingly. */
+ __(
+ 'Every site you build lives in the sidebar on the left. Switch between them anytime. The sidebar is where you’ll find site settings and a quick way to start and stop your site.'
+ ),
+ action: () => __( 'Next' ),
+ };
+}
+
+// Page 2 — the middle beat. Signed-in users learn about building with Studio
+// Code; signed-out users learn about the site overview control panel.
+function workspacePage( chatEnabled: boolean ): GuidePage {
+ if ( chatEnabled ) {
+ return {
+ illustration: 'chat',
+ title: () => __( 'Build by asking' ),
+ description: () =>
+ __(
+ 'Describe what you want in plain language. Add a page, change the design, fix a bug — and our AI agent, Studio Code, builds it.'
+ ),
+ action: () => __( 'Next' ),
+ };
+ }
+ return {
+ illustration: 'overview',
+ title: () => __( 'Manage your site' ),
+ description: () =>
+ __(
+ 'The site overview is your control panel — open the editor and admin tools, or duplicate, export, and manage your site from one place.'
+ ),
+ action: () => __( 'Next' ),
+ };
+}
+
+// Page 3 — the live preview. Signed-in copy calls out the realtime refresh that
+// comes with Studio Code edits; signed-out copy is the plain preview.
+function previewPage( chatEnabled: boolean ): GuidePage {
+ if ( chatEnabled ) {
+ return {
+ illustration: 'preview',
+ title: () => __( 'See your site update in realtime' ),
+ description: () =>
+ /* translators: "on the right" describes the preview in left-to-right layouts; in right-to-left languages it sits on the left — adapt the direction accordingly. */
+ __(
+ 'Your site is shown on the right, automatically refreshing as you make changes with Studio Code. Switch between the front-end, wp-admin, and the database from the toolbar.'
+ ),
+ action: () => __( 'Let’s go' ),
+ };
+ }
+ return {
+ illustration: 'preview',
+ title: () => __( 'See your site inline' ),
+ description: () =>
+ /* translators: "on the right" describes the preview in left-to-right layouts; in right-to-left languages it sits on the left — adapt the direction accordingly. */
+ __(
+ 'Your site is shown on the right. Switch between the front-end, wp-admin, and the database from the toolbar.'
+ ),
+ action: () => __( 'Let’s go' ),
+ };
+}
+
+export function getOrientationGuide( {
+ migrating,
+ chatEnabled,
+}: OrientationVariant ): GuideDefinition {
+ return {
+ pages: [ sitesPage( migrating ), workspacePage( chatEnabled ), previewPage( chatEnabled ) ],
+ };
+}
diff --git a/apps/ui/src/data/onboarding/use-orientation-autostart.test.ts b/apps/ui/src/data/onboarding/use-orientation-autostart.test.ts
new file mode 100644
index 0000000000..c515049dfd
--- /dev/null
+++ b/apps/ui/src/data/onboarding/use-orientation-autostart.test.ts
@@ -0,0 +1,82 @@
+import { describe, expect, it } from 'vitest';
+import { ORIENTATION_GUIDE_VERSION } from './orientation-guide';
+import { deriveOrientationAutostart } from './use-orientation-autostart';
+import type { OnboardingHintsState } from '@/data/core';
+
+const base = {
+ onboardingCompleted: true,
+ siteCount: 1,
+ agentic: { chatEnabled: true, isReady: true },
+ hints: {} as OnboardingHintsState,
+ guideOpen: false,
+ alreadyStarted: false,
+};
+
+describe( 'deriveOrientationAutostart', () => {
+ it( 'opens the chat guide for a fresh install when everything is ready', () => {
+ expect( deriveOrientationAutostart( base ) ).toEqual( { migrating: false, chatEnabled: true } );
+ } );
+
+ it( 'marks the non-chat variant when chat is unavailable (signed out, offline, or opted out)', () => {
+ expect(
+ deriveOrientationAutostart( { ...base, agentic: { chatEnabled: false, isReady: true } } )
+ ).toEqual( { migrating: false, chatEnabled: false } );
+ } );
+
+ it( 'marks the migrating variant when the user opted in from classic', () => {
+ expect(
+ deriveOrientationAutostart( { ...base, hints: { migratedFromClassic: true } } )
+ ).toEqual( { migrating: true, chatEnabled: true } );
+ } );
+
+ it( 'waits until the pre-workbench welcome is done', () => {
+ expect( deriveOrientationAutostart( { ...base, onboardingCompleted: false } ) ).toBeNull();
+ expect( deriveOrientationAutostart( { ...base, onboardingCompleted: undefined } ) ).toBeNull();
+ } );
+
+ it( 'waits until there is at least one site', () => {
+ expect( deriveOrientationAutostart( { ...base, siteCount: 0 } ) ).toBeNull();
+ } );
+
+ it( 'waits until the agentic gate has resolved', () => {
+ expect(
+ deriveOrientationAutostart( { ...base, agentic: { chatEnabled: true, isReady: false } } )
+ ).toBeNull();
+ } );
+
+ it( 'waits until hints have loaded', () => {
+ expect( deriveOrientationAutostart( { ...base, hints: undefined } ) ).toBeNull();
+ } );
+
+ it( 'does not re-open a completed guide of the current version', () => {
+ expect(
+ deriveOrientationAutostart( {
+ ...base,
+ hints: { tourCompletedVersion: ORIENTATION_GUIDE_VERSION },
+ } )
+ ).toBeNull();
+ } );
+
+ it( 'does not re-open a dismissed guide of the current version', () => {
+ expect(
+ deriveOrientationAutostart( {
+ ...base,
+ hints: { tourDismissedVersion: ORIENTATION_GUIDE_VERSION },
+ } )
+ ).toBeNull();
+ } );
+
+ it( 're-arms when the guide version is bumped past the seen version', () => {
+ expect(
+ deriveOrientationAutostart( {
+ ...base,
+ hints: { tourCompletedVersion: ORIENTATION_GUIDE_VERSION - 1 },
+ } )
+ ).toEqual( { migrating: false, chatEnabled: true } );
+ } );
+
+ it( 'does not open while the guide is already open or already started', () => {
+ expect( deriveOrientationAutostart( { ...base, guideOpen: true } ) ).toBeNull();
+ expect( deriveOrientationAutostart( { ...base, alreadyStarted: true } ) ).toBeNull();
+ } );
+} );
diff --git a/apps/ui/src/data/onboarding/use-orientation-autostart.ts b/apps/ui/src/data/onboarding/use-orientation-autostart.ts
new file mode 100644
index 0000000000..1a3eb6dc4a
--- /dev/null
+++ b/apps/ui/src/data/onboarding/use-orientation-autostart.ts
@@ -0,0 +1,123 @@
+import { useEffect, useRef } from 'react';
+import { useOnboardingGuide } from '@/components/onboarding-guide/use-onboarding-guide';
+import { useAgenticFeatures } from '@/data/queries/use-agentic-features';
+import {
+ useOnboardingCompleted,
+ useOnboardingHints,
+ useSetOnboardingHints,
+} from '@/data/queries/use-onboarding-hints';
+import { useSites } from '@/data/queries/use-sites';
+import { ORIENTATION_GUIDE_VERSION } from './orientation-guide';
+import type { OrientationVariant } from './orientation-guide';
+import type { OnboardingHintsState } from '@/data/core';
+
+interface AutostartInputs {
+ onboardingCompleted: boolean | undefined;
+ siteCount: number;
+ agentic: { chatEnabled: boolean; isReady: boolean };
+ hints: OnboardingHintsState | undefined;
+ guideOpen: boolean;
+ alreadyStarted: boolean;
+}
+
+/**
+ * Pure decision: which orientation guide variant (if any) to auto-open.
+ * Returns null unless the user finished the pre-workbench welcome, has at least
+ * one site, the agentic gate has resolved, hints have loaded, nothing is
+ * already showing, and this app session hasn't opened the guide yet.
+ */
+export function deriveOrientationAutostart( {
+ onboardingCompleted,
+ siteCount,
+ agentic,
+ hints,
+ guideOpen,
+ alreadyStarted,
+}: AutostartInputs ): OrientationVariant | null {
+ if ( alreadyStarted || guideOpen ) {
+ return null;
+ }
+ if ( onboardingCompleted !== true || siteCount < 1 || ! agentic.isReady ) {
+ return null;
+ }
+ if ( hints === undefined ) {
+ return null;
+ }
+ const seen =
+ ( hints.tourCompletedVersion ?? 0 ) >= ORIENTATION_GUIDE_VERSION ||
+ ( hints.tourDismissedVersion ?? 0 ) >= ORIENTATION_GUIDE_VERSION;
+ if ( seen ) {
+ return null;
+ }
+ return { migrating: hints.migratedFromClassic ?? false, chatEnabled: agentic.chatEnabled };
+}
+
+// Let the workbench render and settle before the guide appears, so it reads as
+// an entrance rather than part of the initial paint.
+const GUIDE_START_DELAY_MS = 500;
+
+/**
+ * Auto-opens the orientation guide once on first arrival in the workbench.
+ * Mounted in the dashboard layout.
+ */
+export function useOrientationAutostart(): void {
+ const { data: sites } = useSites();
+ const agentic = useAgenticFeatures();
+ const { data: hints } = useOnboardingHints();
+ const setHints = useSetOnboardingHints();
+ const { isOpen, openGuide } = useOnboardingGuide();
+
+ const { data: onboardingCompleted } = useOnboardingCompleted();
+
+ const startedRef = useRef( false );
+ const startTimerRef = useRef< ReturnType< typeof setTimeout > | null >( null );
+
+ useEffect( () => {
+ const variant = deriveOrientationAutostart( {
+ onboardingCompleted,
+ siteCount: sites?.length ?? 0,
+ agentic: { chatEnabled: agentic.chatEnabled, isReady: agentic.isReady },
+ hints,
+ guideOpen: isOpen,
+ alreadyStarted: startedRef.current,
+ } );
+ if ( ! variant ) {
+ return;
+ }
+ // Mark started immediately so a dependency change can't schedule twice.
+ startedRef.current = true;
+ startTimerRef.current = setTimeout( () => {
+ startTimerRef.current = null;
+ openGuide( variant, {
+ onEnd: ( reason ) => {
+ if ( reason === 'completed' ) {
+ setHints.mutate( { tourCompletedVersion: ORIENTATION_GUIDE_VERSION } );
+ } else {
+ setHints.mutate( { tourDismissedVersion: ORIENTATION_GUIDE_VERSION } );
+ }
+ },
+ } );
+ }, GUIDE_START_DELAY_MS );
+ // No timer cleanup here: a dependency change re-runs this effect and
+ // returns early (startedRef guard); clearing on every re-run would
+ // cancel the pending open. The mount-scoped cleanup below handles it.
+ }, [
+ onboardingCompleted,
+ sites?.length,
+ agentic.chatEnabled,
+ agentic.isReady,
+ hints,
+ isOpen,
+ openGuide,
+ setHints,
+ ] );
+
+ useEffect( () => {
+ return () => {
+ if ( startTimerRef.current ) {
+ clearTimeout( startTimerRef.current );
+ startTimerRef.current = null;
+ }
+ };
+ }, [] );
+}
diff --git a/apps/ui/src/data/onboarding/use-orientation-replay.ts b/apps/ui/src/data/onboarding/use-orientation-replay.ts
new file mode 100644
index 0000000000..1212f92ec5
--- /dev/null
+++ b/apps/ui/src/data/onboarding/use-orientation-replay.ts
@@ -0,0 +1,45 @@
+import { useEffect, useRef } from 'react';
+import { useOnboardingGuide } from '@/components/onboarding-guide/use-onboarding-guide';
+import { useConnector } from '@/data/core';
+import { useAgenticFeatures } from '@/data/queries/use-agentic-features';
+import { useOnboardingHints, useSetOnboardingHints } from '@/data/queries/use-onboarding-hints';
+import { ORIENTATION_GUIDE_VERSION } from './orientation-guide';
+
+/**
+ * Reopens the orientation guide when the user picks Help ▸ Getting Started in
+ * the application menu. Finishing or skipping the replay records the seen
+ * version just like the first-run autostart, so the guide never re-appears on
+ * its own afterward. No-ops on surfaces without an OS menu — the connector
+ * subscription simply never fires.
+ */
+export function useOrientationReplay(): void {
+ const connector = useConnector();
+ const agentic = useAgenticFeatures();
+ const { data: hints } = useOnboardingHints();
+ const setHints = useSetOnboardingHints();
+ const { openGuide } = useOnboardingGuide();
+
+ // The menu event fires outside React's data flow, so read the latest variant
+ // inputs through a ref instead of resubscribing every time they change.
+ const variantRef = useRef( { migrating: false, chatEnabled: agentic.chatEnabled } );
+ useEffect( () => {
+ variantRef.current = {
+ migrating: hints?.migratedFromClassic ?? false,
+ chatEnabled: agentic.chatEnabled,
+ };
+ }, [ agentic.chatEnabled, hints?.migratedFromClassic ] );
+
+ useEffect( () => {
+ return connector.onShowGettingStarted( () => {
+ openGuide( variantRef.current, {
+ onEnd: ( reason ) => {
+ setHints.mutate(
+ reason === 'completed'
+ ? { tourCompletedVersion: ORIENTATION_GUIDE_VERSION }
+ : { tourDismissedVersion: ORIENTATION_GUIDE_VERSION }
+ );
+ },
+ } );
+ } );
+ }, [ connector, openGuide, setHints ] );
+}
diff --git a/apps/ui/src/data/queries/use-onboarding-hints.ts b/apps/ui/src/data/queries/use-onboarding-hints.ts
new file mode 100644
index 0000000000..130e605cf3
--- /dev/null
+++ b/apps/ui/src/data/queries/use-onboarding-hints.ts
@@ -0,0 +1,49 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { useConnector } from '@/data/core';
+import type { OnboardingHintsState } from '@/data/core';
+
+// Persisted first-run onboarding state (orientation tour + getting-started
+// checklist). Backed by the connector: desktop → app.json, hosted/web →
+// localStorage. Read once and kept forever — writes are rare and go through the
+// optimistic mutation below.
+
+export const ONBOARDING_HINTS_QUERY_KEY = [ 'onboarding-hints' ] as const;
+export const ONBOARDING_COMPLETED_QUERY_KEY = [ 'onboarding-completed' ] as const;
+
+export function useOnboardingHints() {
+ const connector = useConnector();
+ return useQuery( {
+ queryKey: ONBOARDING_HINTS_QUERY_KEY,
+ queryFn: () => connector.getOnboardingHints(),
+ staleTime: Infinity,
+ meta: { persist: false },
+ } );
+}
+
+// Whether the user has finished (or skipped) the pre-workbench welcome. Gates
+// tour auto-start so it never appears mid-NUX.
+export function useOnboardingCompleted() {
+ const connector = useConnector();
+ return useQuery( {
+ queryKey: ONBOARDING_COMPLETED_QUERY_KEY,
+ queryFn: () => connector.getOnboardingCompleted(),
+ staleTime: Infinity,
+ } );
+}
+
+export function useSetOnboardingHints() {
+ const connector = useConnector();
+ const queryClient = useQueryClient();
+ return useMutation( {
+ mutationFn: ( partial: Partial< OnboardingHintsState > ) =>
+ connector.setOnboardingHints( partial ),
+ // Optimistic so the UI reacts immediately; the connector write is the
+ // source of truth on next launch.
+ onMutate: ( partial ) => {
+ queryClient.setQueryData(
+ ONBOARDING_HINTS_QUERY_KEY,
+ ( current: OnboardingHintsState | undefined ) => ( { ...( current ?? {} ), ...partial } )
+ );
+ },
+ } );
+}
diff --git a/apps/ui/src/ui-classic/router/layout-dashboard/index.tsx b/apps/ui/src/ui-classic/router/layout-dashboard/index.tsx
index 0f52f93d26..30a1269ec9 100644
--- a/apps/ui/src/ui-classic/router/layout-dashboard/index.tsx
+++ b/apps/ui/src/ui-classic/router/layout-dashboard/index.tsx
@@ -7,6 +7,8 @@ import {
} from '@/components/preview-split-frame';
import { SidebarLayout } from '@/components/sidebar-layout';
import { SitePreview } from '@/components/site-preview';
+import { useOrientationAutostart } from '@/data/onboarding/use-orientation-autostart';
+import { useOrientationReplay } from '@/data/onboarding/use-orientation-replay';
import { useSession, useSessionEffectiveEnvironment } from '@/data/queries/use-sessions';
import { useSites } from '@/data/queries/use-sites';
import {
@@ -59,6 +61,10 @@ function DashboardLayoutContent() {
const { sessionId, overviewSiteId, newSessionSiteId } = routePreviewContext;
const { data: sites } = useSites();
const { data: sessionData } = useSession( sessionId );
+ // Open the orientation guide on first workbench arrival, and let Help ▸
+ // Getting Started replay it.
+ useOrientationAutostart();
+ useOrientationReplay();
const preview = useSessionPreviewUI();
const onAnnotationsDone = useSessionPreviewAnnotationsHandler();
const sessionSite = findAiSessionOwnerSite( sites, sessionData?.summary );