Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
44a3e20
Add a first-run orientation guide, replayable from Help
bcotrim Jul 24, 2026
eb0e680
Scope onboarding hints to the guide and share the browser store
bcotrim Jul 24, 2026
b574def
Merge remote-tracking branch 'origin/trunk' into stu-2016-add-orienta…
bcotrim Aug 1, 2026
938d288
Adapt the orientation guide to new/migrating users and Studio Code av…
bcotrim Aug 1, 2026
13dc62f
Merge branch 'trunk' into stu-2016-add-orientation-guide
bcotrim Aug 3, 2026
dc5ece3
Show the What's New announcements in the agentic UI
bcotrim Aug 3, 2026
73a56fa
Make the orientation guide RTL-aware and annotate directional copy
bcotrim Aug 4, 2026
5c9cf83
Make the tour modal a window-drag handle and fix pager dot radius
bcotrim Aug 5, 2026
d2d17a0
Drag the tour window via the backdrop, not the transformed popup
bcotrim Aug 5, 2026
199946e
Merge remote-tracking branch 'origin/stu-2016-add-orientation-guide' …
bcotrim Aug 6, 2026
40bf5ab
Reuse the connector's ipcListener binding for the What's New menu event
bcotrim Aug 6, 2026
98333ae
Gate the workbench guides on having a site, not the onboarding-comple…
bcotrim Aug 6, 2026
6245727
trigger ci
bcotrim Aug 6, 2026
ac841af
Fade the guide's Back button instead of popping it in
bcotrim Aug 6, 2026
f749bda
Make the pager dot shift colour instead of fading in
bcotrim Aug 6, 2026
ee0d661
Trim redundant comments from the What's New autostart hook
bcotrim Aug 6, 2026
91b7e07
Resolve the recorded version in one place and trim CSS comments
bcotrim Aug 7, 2026
96d4cf4
Merge remote-tracking branch 'origin/trunk' into stu-1988-port-whats-…
bcotrim Aug 10, 2026
29e1768
Restore the data-liberation bundles clobbered by the trunk merge
bcotrim Aug 10, 2026
353de1e
Merge remote-tracking branch 'origin/trunk' into stu-1988-port-whats-…
bcotrim Aug 10, 2026
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
8 changes: 3 additions & 5 deletions apps/studio/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,6 @@ export const IPC_VOID_HANDLERS = [
'authenticate',
] as const;

// What's New
// Flip to `true` when shipping new modal content so users who haven't seen the
// current app version get the modal once. Keep at `false` otherwise — the modal
// will only auto-show for first-time users of Studio.
export const FORCE_SHOW_WHATS_NEW = false;
// What's New. Defined in @studio/common so the agentic UI reads the same switch;
// re-exported here so existing imports keep working.
export { FORCE_SHOW_WHATS_NEW } from '@studio/common/lib/whats-new';
7 changes: 7 additions & 0 deletions apps/studio/src/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -240,6 +241,7 @@ export {
getColorScheme,
getGlobalAgentInstructions,
getInstalledAppsAndTerminals,
getOnboardingHints,
getQuitSitesBehavior,
getUserEditor,
getUserLocale,
Expand All @@ -250,6 +252,7 @@ export {
saveAnalyticsEnabled,
saveColorScheme,
saveGlobalAgentInstructions,
saveOnboardingHints,
saveQuitSitesBehavior,
saveUserEditor,
saveUserLocale,
Expand Down Expand Up @@ -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 );
Expand Down
1 change: 1 addition & 0 deletions apps/studio/src/ipc-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions apps/studio/src/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
? [
Expand Down
37 changes: 37 additions & 0 deletions apps/studio/src/modules/user-settings/lib/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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() ) ?? '';
}
Expand Down
10 changes: 5 additions & 5 deletions apps/studio/src/modules/whats-new/components/whats-new-modal.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import interpolateComponents from '@automattic/interpolate-components';
import cliIllustration from '@studio/common/assets/whats-new/cli.svg';
import darkModeIllustration from '@studio/common/assets/whats-new/dark-mode.svg';
import nativePhpIllustration from '@studio/common/assets/whats-new/native-php.svg';
import phpMyAdminIllustration from '@studio/common/assets/whats-new/phpmyadmin.svg';
import studioCodeIllustration from '@studio/common/assets/whats-new/studio-code.svg';
import { Guide } from '@wordpress/components';
import { __, sprintf } from '@wordpress/i18n';
import { ReactNode } from 'react';
import { cx } from 'src/lib/cx';
import { getIpcApi } from 'src/lib/get-ipc-api';
import { getLocalizedLink } from 'src/lib/get-localized-link';
import cliIllustration from 'src/modules/whats-new/assets/cli-illustration.svg';
import darkModeIllustration from 'src/modules/whats-new/assets/dark-mode-illustration.svg';
import nativePhpIllustration from 'src/modules/whats-new/assets/native-php-illustration.svg';
import phpMyAdminIllustration from 'src/modules/whats-new/assets/phpmyadmin-illustration.svg';
import studioCodeIllustration from 'src/modules/whats-new/assets/studio-code-illustration.svg';
import { useI18nLocale } from 'src/stores';

interface WhatsNewPage {
Expand Down
2 changes: 2 additions & 0 deletions apps/studio/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ),
Expand Down
11 changes: 11 additions & 0 deletions apps/studio/src/storage/storage-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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: {},
Expand Down
1 change: 1 addition & 0 deletions apps/studio/src/storage/user-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ type UserDataSafeKeys =
| 'cliAutoInstalled'
| 'cliUserUninstalled'
| 'wapuuScore'
| 'onboardingHints'
| 'lastNightlyUpdateCheck'
| 'nightlyPromptResult'
| 'agenticUiBannerDismissed'
Expand Down
5 changes: 4 additions & 1 deletion apps/ui/src/app/app-providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -42,7 +43,9 @@ function ThemedApp( { children }: PropsWithChildren ) {
}, [ colorScheme ] );
return (
<ThemeProvider isRoot color={ themeColor } density="compact">
<Tooltip.Provider>{ children }</Tooltip.Provider>
<Tooltip.Provider>
<OnboardingGuideProvider>{ children }</OnboardingGuideProvider>
</Tooltip.Provider>
</ThemeProvider>
);
}
Expand Down
40 changes: 40 additions & 0 deletions apps/ui/src/components/onboarding-guide/illustrations.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import cliIllustration from '@studio/common/assets/whats-new/cli.svg';
import darkModeIllustration from '@studio/common/assets/whats-new/dark-mode.svg';
import nativePhpIllustration from '@studio/common/assets/whats-new/native-php.svg';
import phpMyAdminIllustration from '@studio/common/assets/whats-new/phpmyadmin.svg';
import studioCodeIllustration from '@studio/common/assets/whats-new/studio-code.svg';
import { __, sprintf } from '@wordpress/i18n';
import styles from './style.module.css';
import type { GuideIllustrationId } from '@/data/onboarding/guide';

// Shared with the classic renderer's What's New modal. The orientation ids have
// no art yet and fall through to the tinted placeholder slot below.
const ILLUSTRATIONS: Partial< Record< GuideIllustrationId, string > > = {
'studio-code': studioCodeIllustration,
'native-php': nativePhpIllustration,
'dark-mode': darkModeIllustration,
phpmyadmin: phpMyAdminIllustration,
cli: cliIllustration,
};

export function hasIllustration( id: GuideIllustrationId ): boolean {
return Boolean( ILLUSTRATIONS[ id ] );
}

export function GuideIllustration( { id, title }: { id: GuideIllustrationId; title: string } ) {
const source = ILLUSTRATIONS[ id ];
if ( ! source ) {
return <div className={ styles.illustration } data-illustration={ id } />;
}
return (
<img
className={ styles.illustration }
src={ source }
alt={ sprintf(
/* translators: %s is the title of the guide page the illustration belongs to. */
__( 'Illustration for %s' ),
title
) }
/>
);
}
128 changes: 128 additions & 0 deletions apps/ui/src/components/onboarding-guide/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { __ } from '@wordpress/i18n';
import { Button, Dialog, Text, VisuallyHidden } from '@wordpress/ui';
import { clsx } from 'clsx';
import { useRef, useState } from 'react';
import { LearnMoreLink } from '@/components/learn-more';
import { GuideIllustration, hasIllustration } from './illustrations';
import styles from './style.module.css';
import type { GuideDefinition } from '@/data/onboarding/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;
// Reserve the link row for the whole guide, not per page, so paging through a
// guide where only some pages link out doesn't resize the modal.
const reservesLearnMore = guide.pages.some( ( { learnMore } ) => learnMore );
// Without this the dialog focuses the first tabbable child, which on a page
// that links out is the "Learn more" link — so Enter would open the docs
// instead of advancing.
const advanceRef = useRef< HTMLButtonElement >( null );

const goNext = () => {
if ( isLast ) {
onComplete();
} else {
setPageIndex( ( index ) => index + 1 );
}
};
const goBack = () => setPageIndex( ( index ) => Math.max( 0, index - 1 ) );

return (
<Dialog.Root
open
modal
// No dismissal by clicking the backdrop — only the close button, Esc,
// or finishing the guide.
disablePointerDismissal
onOpenChange={ ( open, details ) => {
if ( open ) {
return;
}
if ( details.reason === 'escape-key' || details.reason === 'close-press' ) {
onDismiss();
}
} }
>
<Dialog.Popup
size="small"
className={ styles.popup }
initialFocus={ advanceRef }
data-orientation-guide
>
<GuideIllustration id={ page.illustration } title={ page.title() } />
<Dialog.CloseIcon
label={ __( 'Skip' ) }
className={ clsx(
styles.close,
hasIllustration( page.illustration ) && styles.closeOverArt
) }
/>
<Dialog.Content className={ styles.content }>
{ /* The dialog's accessible name and description follow the active page.
The visible copy is rendered separately below so that every page can
share one grid cell. */ }
<VisuallyHidden render={ <Dialog.Title /> }>{ page.title() }</VisuallyHidden>
<VisuallyHidden render={ <Dialog.Description /> }>{ page.description() }</VisuallyHidden>
{ /* All pages occupy the same grid cell, so the box is as tall as the
longest one and advancing never resizes the modal. Rendering them
identically — rather than measuring the active page — is what keeps
the height honest once translations change the copy. */ }
{ guide.pages.map( ( guidePage, index ) => (
<div
key={ index }
className={ clsx( styles.page, index !== pageIndex && styles.pageHidden ) }
>
<Text variant="heading-xl" className={ styles.title }>
{ guidePage.title() }
</Text>
<Text variant="body-md" className={ styles.description }>
{ guidePage.description() }
</Text>
{ reservesLearnMore ? (
<div className={ styles.learnMoreRow }>
{ guidePage.learnMore ? (
<LearnMoreLink docsLinksKey={ guidePage.learnMore } />
) : null }
</div>
) : null }
</div>
) ) }
</Dialog.Content>
<Dialog.Footer className={ styles.footer }>
<div className={ styles.footerStart }>
{ ! isFirst ? (
<Button variant="minimal" tone="neutral" onClick={ goBack }>
{ __( 'Back' ) }
</Button>
) : null }
</div>
<div className={ styles.pager } aria-hidden="true">
{ guide.pages.map( ( _, index ) => (
<span
key={ index }
className={ clsx( styles.dot, index === pageIndex && styles.dotActive ) }
/>
) ) }
</div>
<div className={ styles.footerEnd }>
<Button ref={ advanceRef } variant="solid" tone="brand" onClick={ goNext }>
{ page.action() }
</Button>
</div>
</Dialog.Footer>
</Dialog.Popup>
</Dialog.Root>
);
}
Loading