From f3171a75cff4c8fbcc555343128cf92f52c2991b Mon Sep 17 00:00:00 2001 From: Shaun Andrews Date: Thu, 23 Jul 2026 09:21:46 -0400 Subject: [PATCH] Agentic UI: Add signed-out sign-in experience Co-Authored-By: Claude Opus 4.8 (1M context) --- .../add-site/components/pull-remote-site.tsx | 2 +- .../agentic-signin-banner/index.tsx | 123 ++++++++++- .../agentic-signin-banner/style.module.css | 205 +++++++++++++++++- .../settings-view/account-section.test.tsx | 6 + .../settings-view/account-section.tsx | 56 +++-- .../settings-view/usage-panel.test.tsx | 4 +- .../components/site-dropdown/main-view.tsx | 4 +- .../src/components/site-list/index.test.tsx | 19 ++ apps/ui/src/components/site-list/index.tsx | 13 +- .../site-overview-view/index.test.tsx | 21 +- .../components/site-overview-view/index.tsx | 4 +- .../site-overview-view/style.module.css | 28 ++- apps/ui/src/components/user-menu/index.tsx | 89 ++------ .../src/components/user-menu/style.module.css | 52 ++--- .../ui-classic/router/route-welcome/index.tsx | 2 +- 15 files changed, 470 insertions(+), 158 deletions(-) diff --git a/apps/studio/src/modules/add-site/components/pull-remote-site.tsx b/apps/studio/src/modules/add-site/components/pull-remote-site.tsx index a186215c23..c6b00b0a83 100644 --- a/apps/studio/src/modules/add-site/components/pull-remote-site.tsx +++ b/apps/studio/src/modules/add-site/components/pull-remote-site.tsx @@ -28,7 +28,7 @@ function SiteSyncDescription( { children }: PropsWithChildren ) { return (
-
{ __( 'Sign in to get started' ) }
+
{ __( 'Log in to get started' ) }
{ __( 'Connect your WordPress.com account to access your sites.' ) }
diff --git a/apps/ui/src/components/agentic-signin-banner/index.tsx b/apps/ui/src/components/agentic-signin-banner/index.tsx index cef87d1d18..d4b22d9017 100644 --- a/apps/ui/src/components/agentic-signin-banner/index.tsx +++ b/apps/ui/src/components/agentic-signin-banner/index.tsx @@ -1,15 +1,35 @@ import { useNavigate } from '@tanstack/react-router'; import { __ } from '@wordpress/i18n'; -import { Button } from '@wordpress/ui'; -import { useEffect, useRef } from 'react'; +import { Button, Tooltip } from '@wordpress/ui'; +import { clsx } from 'clsx'; +import { useEffect, useRef, useState } from 'react'; +import { DotGrid } from '@/components/dot-grid'; +import { ProgressiveBlur } from '@/components/progressive-blur'; import { useAgenticFeatures } from '@/data/queries/use-agentic-features'; import { useLogin } from '@/data/queries/use-auth-user'; +import { useSidebarCollapsed } from '@/hooks/use-sidebar-collapsed'; +import { EmptyBackground } from '@/ui-classic/components/session-view/empty-background'; import styles from './style.module.css'; import type { AgenticFeatureReason } from '@/data/queries/use-agentic-features'; +const TAGLINE_INTERVAL_MS = 4000; + +// The illustrated, collapsible sign-in band pinned to the bottom of the site overview. export function AgenticSigninBanner() { const { enabled, reason } = useAgenticFeatures(); + const login = useLogin(); const navigate = useNavigate(); + const sidebarCollapsed = useSidebarCollapsed(); + const [ minimized, setMinimized ] = useState( false ); + const [ taglineIndex, setTaglineIndex ] = useState( 0 ); + + const taglines = [ + __( 'Let Studio code it for you' ), + __( 'Share a preview online' ), + __( 'Push to your live site' ), + __( 'Build a theme or plugin' ), + ]; + const taglineCount = taglines.length; // `authenticate()` resolves when the browser opens, not when OAuth // completes, so a finished login only surfaces here as a signed-out → @@ -23,29 +43,112 @@ export function AgenticSigninBanner() { } }, [ enabled, reason, navigate ] ); + // Rotate the tagline while collapsed. + useEffect( () => { + if ( ! minimized ) { + return; + } + const id = window.setInterval( () => { + setTaglineIndex( ( index ) => ( index + 1 ) % taglineCount ); + }, TAGLINE_INTERVAL_MS ); + return () => window.clearInterval( id ); + }, [ minimized, taglineCount ] ); + if ( reason !== 'signed-out' ) { return null; } - return ; + const loginButton = ( + + login.mutate() } + > + { minimized ? __( 'Log in' ) : __( 'Log in with WordPress.com' ) } + + } + /> + }> + { minimized ? __( 'Log in to WordPress.com' ) : __( 'Opens your browser to log in' ) } + + + ); + + // Hidden: a rotating tagline next to the log-in button, pinned clear of the + // preview toggle and above the footer blur. + if ( minimized ) { + return ( +
+ + { loginButton } +
+ ); + } + + return ( +
+ + +
+ +
+

{ __( 'Let Studio code it for you' ) }

+

+ { __( + 'An AI powered WordPress expert that can build a site, theme, or plugin, and help you share and publish.' + ) } +

+
+ { loginButton } + +
+
+
+
+ ); } -// The banner without the route-aware behaviour, for surfaces that must stay -// put once the user signs in (e.g. Settings). +// A compact inline notice (no route-aware redirect) for surfaces that keep the +// prompt in the content flow and must stay put once the user signs in — e.g. the +// Settings Usage panel. export function SigninNotice() { const login = useLogin(); return ( -
-
-

{ __( 'Sign in to do more with Studio' ) }

-
    +
    +
    +

    { __( 'Log in to do more with Studio' ) }

    +
    • { __( 'Chat with a WordPress expert that builds and edits your site for you' ) }
    • { __( 'Share your work instantly with preview links' ) }
    • { __( "Publish to a real WordPress.com site when you're ready" ) }
    -
    +
    ; }, + Tooltip: { + Root: ( { children }: { children?: ReactNode } ) => <>{ children }, + Trigger: ( { render }: { render?: ReactNode } ) => <>{ render }, + Popup: () => null, + Positioner: () => null, + }, } ) ); vi.mock( '@/components/gravatar', () => ( { diff --git a/apps/ui/src/components/settings-view/account-section.tsx b/apps/ui/src/components/settings-view/account-section.tsx index 15b59029ab..ac93b482a8 100644 --- a/apps/ui/src/components/settings-view/account-section.tsx +++ b/apps/ui/src/components/settings-view/account-section.tsx @@ -1,5 +1,5 @@ import { __ } from '@wordpress/i18n'; -import { Button } from '@wordpress/ui'; +import { Button, Tooltip } from '@wordpress/ui'; import { clsx } from 'clsx'; import { Gravatar } from '@/components/gravatar'; import { useConnector } from '@/data/core'; @@ -19,24 +19,42 @@ function AccountHelpActions() { return (
    - - + + openLink( getLocalizedLink( locale, 'docsStudio' ) ) } + > + { __( 'Docs' ) } + + } + /> + }> + { __( 'Documentation' ) } + + + + openLink( REPORT_ISSUE_URL ) } + > + { __( 'Report an issue' ) } + + } + /> + }> + { __( 'Report an issue or request a feature' ) } + +
    ); } diff --git a/apps/ui/src/components/settings-view/usage-panel.test.tsx b/apps/ui/src/components/settings-view/usage-panel.test.tsx index 701dbfae12..7c0896b5d4 100644 --- a/apps/ui/src/components/settings-view/usage-panel.test.tsx +++ b/apps/ui/src/components/settings-view/usage-panel.test.tsx @@ -259,7 +259,7 @@ describe( 'UsagePanel', () => { expect( screen.queryByRole( 'button', { name: 'Delete all preview sites' } ) ).not.toBeInTheDocument(); - expect( screen.queryByLabelText( 'Sign in to Studio' ) ).not.toBeInTheDocument(); + expect( screen.queryByLabelText( 'Log in to Studio' ) ).not.toBeInTheDocument(); } ); it( 'surfaces a deletion error inline', () => { @@ -281,7 +281,7 @@ describe( 'UsagePanel', () => { render( ); - expect( screen.getByLabelText( 'Sign in to Studio' ) ).toBeInTheDocument(); + expect( screen.getByLabelText( 'Log in to Studio' ) ).toBeInTheDocument(); expect( screen.queryByText( /active preview site/ ) ).not.toBeInTheDocument(); expect( screen.getAllByRole( 'img', { name: 'Unavailable' } ) ).toHaveLength( 2 ); // Studio Code needs an account, so the Alpha pricing copy stays hidden. diff --git a/apps/ui/src/components/site-dropdown/main-view.tsx b/apps/ui/src/components/site-dropdown/main-view.tsx index b5ae74f67e..3cec6282f3 100644 --- a/apps/ui/src/components/site-dropdown/main-view.tsx +++ b/apps/ui/src/components/site-dropdown/main-view.tsx @@ -87,7 +87,7 @@ function getPreviewPanelCopy( if ( isOffline ) { return __( 'Go online to share a review link.' ); } - return __( 'Sign in to share a review link.' ); + return __( 'Log in to share a review link.' ); } function getLivePanelCopy( agenticEnabled: boolean, isOffline: boolean ): string { @@ -97,7 +97,7 @@ function getLivePanelCopy( agenticEnabled: boolean, isOffline: boolean ): string if ( isOffline ) { return __( 'Go online to publish your site.' ); } - return __( 'Sign in to publish your site.' ); + return __( 'Log in to publish your site.' ); } export function MainView( { site, activity, onSetupClick, onDisconnectClick }: Props ) { diff --git a/apps/ui/src/components/site-list/index.test.tsx b/apps/ui/src/components/site-list/index.test.tsx index b20b8d50c5..ee1fb7972c 100644 --- a/apps/ui/src/components/site-list/index.test.tsx +++ b/apps/ui/src/components/site-list/index.test.tsx @@ -232,6 +232,25 @@ describe( 'SiteList', () => { } ); } ); + it( 'shows the selected site solid without the overview shortcut when signed out', () => { + vi.mocked( useAgenticFeatures ).mockReturnValue( { + enabled: false, + reason: 'signed-out', + isReady: true, + } ); + paramsMock = { siteId: 'stopped-site' }; + pathnameMock = '/sites/stopped-site/overview'; + + render( ); + + const stoppedRow = screen.getByText( 'Stopped Site' ).closest( 'section' )!; + const className = stoppedRow.getAttribute( 'class' ) ?? ''; + + expect( className ).toContain( 'siteActive' ); + expect( className ).not.toContain( 'siteContextActive' ); + expect( screen.queryByRole( 'button', { name: 'Site overview' } ) ).not.toBeInTheDocument(); + } ); + it( 'opens the site overview from the row gear without opening the latest chat', () => { render( ); diff --git a/apps/ui/src/components/site-list/index.tsx b/apps/ui/src/components/site-list/index.tsx index 347e0f10b5..53f3561f1b 100644 --- a/apps/ui/src/components/site-list/index.tsx +++ b/apps/ui/src/components/site-list/index.tsx @@ -505,6 +505,11 @@ function SiteSection( { const navigate = useNavigate(); const sectionRef = useRef< HTMLElement >( null ); const isActive = isChatActive || isContextActive; + // Signed out, a site's home is its overview, so the context-active row is + // simply "the selected site" — show it solid-selected (no dashed outline, + // no overview shortcut), matching how chat-active looks when signed in. + const showSelected = isChatActive || ( isContextActive && ! agenticEnabled ); + const showContextOutline = isContextActive && agenticEnabled; // Keep the active site visible — e.g. when launch restores a site that // sits below the sidebar's fold. `nearest` no-ops when already visible. useEffect( () => { @@ -555,8 +560,8 @@ function SiteSection( { ref={ sectionRef } className={ clsx( styles.site, - isChatActive && styles.siteActive, - isContextActive && styles.siteContextActive + showSelected && styles.siteActive, + showContextOutline && styles.siteContextActive ) } >
    - + { agenticEnabled ? : null }
    diff --git a/apps/ui/src/components/site-overview-view/index.test.tsx b/apps/ui/src/components/site-overview-view/index.test.tsx index 1e83037712..4e0a37b243 100644 --- a/apps/ui/src/components/site-overview-view/index.test.tsx +++ b/apps/ui/src/components/site-overview-view/index.test.tsx @@ -1,4 +1,5 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { Tooltip } from '@wordpress/ui'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { useConnector } from '@/data/core'; import { useAgenticFeatures } from '@/data/queries/use-agentic-features'; @@ -36,6 +37,16 @@ vi.mock( '@/components/delete-site-dialog', () => ( { open ?
    Delete dialog
    : null, } ) ); +// Canvas-backed W background; jsdom has no 2D context, so stub it out. +vi.mock( '@/ui-classic/components/session-view/empty-background', () => ( { + EmptyBackground: () => null, +} ) ); + +// Canvas-backed dot-grid backdrop; jsdom has no 2D context, so stub it out. +vi.mock( '@/components/dot-grid', () => ( { + DotGrid: () => null, +} ) ); + vi.mock( '@/components/site-dropdown', () => ( { SiteDropdown: ( props: { site: SiteDetails; showSiteIcon?: boolean; showStatus?: boolean } ) => { siteDropdownMock( props ); @@ -138,7 +149,9 @@ describe( 'SiteOverviewView', () => { function renderView( activeTab: 'overview' | 'general' | 'debugging' = 'overview' ) { return render( - + + + ); } @@ -241,9 +254,7 @@ describe( 'SiteOverviewView', () => { renderView(); - expect( - screen.getByRole( 'heading', { name: 'Sign in to do more with Studio' } ) - ).toBeVisible(); + expect( screen.getByRole( 'heading', { name: 'Let Studio code it for you' } ) ).toBeVisible(); fireEvent.click( screen.getByRole( 'button', { name: 'Log in with WordPress.com' } ) ); @@ -254,7 +265,7 @@ describe( 'SiteOverviewView', () => { renderView(); expect( - screen.queryByRole( 'heading', { name: 'Sign in to do more with Studio' } ) + screen.queryByRole( 'heading', { name: 'Let Studio code it for you' } ) ).not.toBeInTheDocument(); } ); diff --git a/apps/ui/src/components/site-overview-view/index.tsx b/apps/ui/src/components/site-overview-view/index.tsx index feb84c7553..3d155724a4 100644 --- a/apps/ui/src/components/site-overview-view/index.tsx +++ b/apps/ui/src/components/site-overview-view/index.tsx @@ -165,9 +165,8 @@ function SiteOverviewBody( {
    - + -
    { isBlockTheme ? ( @@ -274,6 +273,7 @@ function SiteOverviewBody( {
    + { activeTab === 'overview' ? : null }
    diff --git a/apps/ui/src/components/site-overview-view/style.module.css b/apps/ui/src/components/site-overview-view/style.module.css index 2b4dab4d89..0933b76162 100644 --- a/apps/ui/src/components/site-overview-view/style.module.css +++ b/apps/ui/src/components/site-overview-view/style.module.css @@ -69,6 +69,19 @@ outline: none; } +/* Fills the scroll viewport so the sign-in banner (margin-top: auto) can sit + at the very bottom of the panel when the content above is short. */ +.overviewPanel { + outline: none; + flex: 1 0 auto; + display: flex; + flex-direction: column; +} + +.overviewPanel[hidden] { + display: none; +} + .scroll { flex: 1; min-height: 0; @@ -112,10 +125,14 @@ box-sizing: border-box; width: 100%; max-width: 760px; - /* Extra bottom padding keeps the last content clear of the overlaid - footer toolbar. */ - padding: var(--wpds-dimension-padding-xl) var(--wpds-dimension-padding-xl) - calc(var(--wpds-dimension-padding-2xl) + 46px); + /* Fill the scroll viewport so a short overview still lets the sign-in + banner settle against the bottom. */ + min-height: 100%; + display: flex; + flex-direction: column; + /* Bottom padding == the overlaid footer toolbar height, so the composer + rests right above the footer controls like the chat composer does. */ + padding: var(--wpds-dimension-padding-xl) var(--wpds-dimension-padding-xl) 46px; } .buttonSection h2 { @@ -200,7 +217,6 @@ @media (max-width: 760px) { .content { - padding: var(--wpds-dimension-padding-xl) var(--wpds-dimension-padding-md) - calc(var(--wpds-dimension-padding-2xl) + 46px); + padding: var(--wpds-dimension-padding-xl) var(--wpds-dimension-padding-md) 46px; } } diff --git a/apps/ui/src/components/user-menu/index.tsx b/apps/ui/src/components/user-menu/index.tsx index 23bb4125f1..9e18f536bc 100644 --- a/apps/ui/src/components/user-menu/index.tsx +++ b/apps/ui/src/components/user-menu/index.tsx @@ -1,98 +1,39 @@ import { useNavigate } from '@tanstack/react-router'; import { __ } from '@wordpress/i18n'; -import { cog } from '@wordpress/icons'; +import { Icon, settings } from '@wordpress/icons'; import { IconButton } from '@wordpress/ui'; import { Gravatar } from '@/components/gravatar'; -import * as Menu from '@/components/menu'; import { SidebarButton } from '@/components/sidebar-button'; -import { useConnector } from '@/data/core'; -import { useAuthUser, useLogin, useLogout } from '@/data/queries/use-auth-user'; -import { useUserLocale } from '@/data/queries/use-user-locale'; +import { useAuthUser } from '@/data/queries/use-auth-user'; import { useColorScheme } from '@/hooks/use-color-scheme'; -import { useOffline } from '@/hooks/use-offline'; -import { getLocalizedLink, REPORT_ISSUE_URL } from '@/lib/docs-links'; import { drawerIcon } from '@/lib/icons'; import styles from './style.module.css'; -const WPCOM_PROFILE_URL = 'https://wordpress.com/me'; - type Props = { onToggleSidebar: () => void; }; export function UserMenu( { onToggleSidebar }: Props ) { - const connector = useConnector(); const { data: user } = useAuthUser(); - const login = useLogin(); - const logout = useLogout(); const navigate = useNavigate(); - - const isOffline = useOffline(); - const locale = useUserLocale(); const themeIsDark = useColorScheme() === 'dark'; - const openLink = ( url: string ) => { - void connector.openExternalUrl( url ); - }; - return (
    - { user ? ( - - - - { user.displayName } - - } - /> - -
    - { user.email } -
    - void navigate( { to: '/settings' } ) }> - { __( 'Settings' ) } - - openLink( WPCOM_PROFILE_URL ) }> - { __( 'Edit WordPress.com profile' ) } - - openLink( getLocalizedLink( locale, 'docsStudio' ) ) } - > - { __( 'Documentation' ) } - - openLink( REPORT_ISSUE_URL ) }> - { __( 'Report an issue' ) } - - - logout.mutate() }>{ __( 'Log out' ) } -
    -
    - ) : ( - login.mutate() } - > - { __( 'Log in with WordPress.com' ) } - - ) } - { ! user ? ( - // Logged in, Settings lives in the user menu; logged out - // there is no menu, so keep the page reachable here. - void navigate( { to: '/settings' } ) } - /> - ) : null } + void navigate( { to: '/settings' } ) } + > + { user ? ( + + ) : ( + + ) } + { __( 'Settings' ) } +

    { __( 'Welcome to Studio' ) }

    - { __( 'Sign in with a free WordPress.com account to unlock everything Studio offers.' ) } + { __( 'Log in with a free WordPress.com account to unlock everything Studio offers.' ) }

    • { __( 'Chat with a WordPress expert that builds and edits your site' ) }