Skip to content
Open
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
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
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
154 changes: 154 additions & 0 deletions apps/ui/src/components/onboarding-guide/illustrations/chat.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<svg viewBox="0 0 16 16" aria-hidden="true">
<path
d="M8 3.5 V12.5 M3.5 8 H12.5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
);
}

function SendGlyph() {
return (
<svg viewBox="0 0 16 16" aria-hidden="true">
<path
d="M8 12.5 V4 M4.5 7.5 L8 4 L11.5 7.5"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}

function FileGlyph() {
return (
<svg className={ styles.toolIcon } viewBox="0 0 16 16" aria-hidden="true">
<path
d="M4 2 H9 L12.5 5.5 V14 H4 Z M9 2 V5.5 H12.5"
fill="none"
stroke="currentColor"
strokeWidth="1.1"
strokeLinejoin="round"
/>
</svg>
);
}

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 (
<div className={ styles.chatScene }>
<button
type="button"
className={ styles.replayButton }
style={ { opacity: replayOp, pointerEvents: replayOp > 0.5 ? 'auto' : 'none' } }
onClick={ restart }
>
{ __( 'Replay' ) }
</button>
<div className={ styles.chatConversation }>
<div
className={ styles.userBubble }
style={ {
opacity: bubbleP,
transform: `translateY(${ ( 1 - bubbleP ) * 46 }px) scale(${ 0.9 + 0.1 * bubbleP })`,
} }
>
{ prompt }
</div>
<div className={ styles.assistantText } style={ { opacity: assistantOp } }>
<StreamingText text={ reply } count={ replyLen } caretClassName={ styles.streamCaret } />
</div>
<span className={ styles.toolChip } style={ { opacity: toolOp } }>
<FileGlyph />
<span className={ styles.toolShimmer }>{ __( 'Reading' ) } front-page.php</span>
</span>
</div>
<div className={ styles.composer } style={ { transform: `translateY(${ composerY }px)` } }>
<div className={ styles.composerInput }>
{ showPlaceholder ? (
<span className={ styles.composerPlaceholder }>{ placeholder }</span>
) : null }
{ typing ? (
<StreamingText
text={ prompt }
count={ promptLen }
className={ styles.composerTyped }
caretClassName={ styles.composerCaret }
/>
) : null }
</div>
<div className={ styles.composerBar }>
<span className={ styles.plusButton }>
<PlusGlyph />
</span>
<div className={ styles.composerButtons }>
{ sent ? (
<span className={ styles.stopButton }>
<span className={ styles.stopGlyph } />
</span>
) : null }
<span
className={ clsx(
styles.sendButton,
( sent || promptLen > 0 ) && styles.sendButtonActive
) }
>
<SendGlyph />
</span>
</div>
</div>
</div>
</div>
);
}
Loading
Loading