Skip to content
25 changes: 17 additions & 8 deletions apps/ui/src/components/agentic-signin-banner/style.module.css
Original file line number Diff line number Diff line change
@@ -1,32 +1,41 @@
.root {
display: flex;
align-items: flex-start;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--wpds-dimension-padding-lg);
padding: var(--wpds-dimension-padding-lg);
border-radius: var(--wpds-border-radius-sm);
margin-bottom: calc(var(--wpds-dimension-padding-xl) * 1.25);
padding: var(--wpds-dimension-padding-lg) var(--wpds-dimension-padding-xl);
border: 1px solid var(--wpds-color-stroke-surface-neutral);
border-radius: var(--wpds-border-radius-lg);
background: var(--wpds-color-bg-surface-neutral-weak);
}

.text {
flex: 1 1 auto;
min-width: 0;
}

.heading {
margin: 0;
margin: 0 0 var(--wpds-dimension-padding-sm);
font-size: var(--wpds-typography-font-size-md);
line-height: var(--wpds-typography-line-height-md);
font-weight: var(--wpds-typography-font-weight-medium);
color: var(--wpds-color-fg-content-neutral);
}

.benefits {
margin: 8px 0 0;
padding: 0 0 0 20px;
margin: 0;
padding-inline-start: var(--wpds-dimension-padding-lg);
display: flex;
flex-direction: column;
gap: var(--wpds-dimension-padding-xs);
font-size: var(--wpds-typography-font-size-sm);
line-height: var(--wpds-typography-line-height-sm);
color: var(--wpds-color-fg-content-neutral-weak);
line-height: 1.5;
}

.actions {
flex: 0 0 auto;
flex-shrink: 0;
align-self: center;
}
38 changes: 38 additions & 0 deletions apps/ui/src/components/menu/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ContextMenu as BaseContextMenu } from '@base-ui/react/context-menu';
import { Menu as BaseMenu } from '@base-ui/react/menu';
import { privateApis } from '@wordpress/theme';
import { forwardRef } from 'react';
Expand All @@ -18,6 +19,8 @@ export const Root = BaseMenu.Root;
export const Trigger = BaseMenu.Trigger;
export const RadioGroup = BaseMenu.RadioGroup;
export const SubmenuRoot = BaseMenu.SubmenuRoot;
export const ContextMenuRoot = BaseContextMenu.Root;
export const ContextMenuTrigger = BaseContextMenu.Trigger;

type PopupProps = {
children: ReactNode;
Expand Down Expand Up @@ -74,6 +77,41 @@ export function Popup( {
);
}

/**
* Popup for context menus. Same chrome as `Popup`, but passes no `side`/
* `align`/offsets: with those undefined, Base UI's positioner anchors a
* context menu at the pointer instead of a trigger edge.
*/
export function ContextPopup( {
children,
className,
onClick,
onPointerDown,
}: {
children: ReactNode;
className?: string;
onClick?: MouseEventHandler< HTMLElement >;
onPointerDown?: PointerEventHandler< HTMLElement >;
} ) {
return (
<BaseMenu.Portal>
<BaseMenu.Positioner className={ styles.positioner }>
{ /* Re-establish density context outside the app-root ThemeProvider,
same as `Popup` above. */ }
<ThemeProvider density="compact">
<BaseMenu.Popup
className={ `${ styles.popup } ${ motionStyles.motion } ${ className ?? '' }` }
onClick={ onClick }
onPointerDown={ onPointerDown }
>
{ children }
</BaseMenu.Popup>
</ThemeProvider>
</BaseMenu.Positioner>
</BaseMenu.Portal>
);
}

type ItemProps = ComponentPropsWithoutRef< typeof BaseMenu.Item >;

export const Item = forwardRef< ElementRef< typeof BaseMenu.Item >, ItemProps >( function Item(
Expand Down
15 changes: 10 additions & 5 deletions apps/ui/src/components/offline-banner/style.module.css
Original file line number Diff line number Diff line change
@@ -1,19 +1,24 @@
/* Keep in sync with agentic-signin-banner/style.module.css so the two
banners read as one component wherever they stack. */
.root {
padding: var(--wpds-dimension-padding-lg);
border-radius: var(--wpds-border-radius-sm);
margin-bottom: calc(var(--wpds-dimension-padding-xl) * 1.25);
padding: var(--wpds-dimension-padding-lg) var(--wpds-dimension-padding-xl);
border: 1px solid var(--wpds-color-stroke-surface-neutral);
border-radius: var(--wpds-border-radius-lg);
background: var(--wpds-color-bg-surface-neutral-weak);
}

.heading {
margin: 0;
margin: 0 0 var(--wpds-dimension-padding-sm);
font-size: var(--wpds-typography-font-size-md);
line-height: var(--wpds-typography-line-height-md);
font-weight: var(--wpds-typography-font-weight-medium);
color: var(--wpds-color-fg-content-neutral);
}

.description {
margin: 8px 0 0;
margin: 0;
font-size: var(--wpds-typography-font-size-sm);
line-height: var(--wpds-typography-line-height-sm);
color: var(--wpds-color-fg-content-neutral-weak);
line-height: 1.5;
}
41 changes: 41 additions & 0 deletions apps/ui/src/components/preview-toggle-button/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { __ } from '@wordpress/i18n';
import { Button, Icon, Tooltip } from '@wordpress/ui';
import { useOptionalSessionPreviewUI } from '@/hooks/use-session-ui';
import { drawerIcon } from '@/lib/icons';
import styles from './style.module.css';

/**
* Bottom-toolbar button that shows/hides the site preview panel. Renders
* nothing outside the dashboard layout (no SessionUIProvider hosting a
* preview panel).
*/
export function PreviewToggleButton() {
const preview = useOptionalSessionPreviewUI();

if ( ! preview ) {
return null;
}

const label = preview.open ? __( 'Hide preview' ) : __( 'Show preview' );

return (
<Tooltip.Root>
<Tooltip.Trigger
render={
<Button
type="button"
variant="minimal"
tone="neutral"
size="small"
className={ styles.button }
aria-label={ label }
onClick={ preview.toggle }
/>
}
>
<Icon icon={ drawerIcon } size={ 26 } className={ styles.icon } />
</Tooltip.Trigger>
<Tooltip.Popup positioner={ <Tooltip.Positioner side="top" /> }>{ label }</Tooltip.Popup>
</Tooltip.Root>
);
}
29 changes: 29 additions & 0 deletions apps/ui/src/components/preview-toggle-button/style.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/* Mirrors the chat footer's icon-button treatment (see session-view's
classicComposerTextButton/classicComposerIconButton) so the toggle looks
the same wherever it appears. */
.button {
--wp-ui-button-font-weight: var(--wpds-typography-font-weight-regular);
--wp-ui-button-font-size: var(--wpds-typography-font-size-sm);
--wp-ui-button-aspect-ratio: 1;
--wp-ui-button-background-color-active: var(--wpds-color-bg-interactive-neutral-weak-active);
--wp-ui-button-border-color-active: var(--wpds-color-bg-interactive-neutral-weak-active);
--wp-ui-button-min-width: unset;
--wp-ui-button-padding-inline: 0;

white-space: nowrap;
-webkit-app-region: no-drag;
}

.button:hover,
.button:focus,
.button:focus-visible {
background: var(--wpds-color-bg-interactive-neutral-weak-active);
}

.icon {
/* Match @wordpress/ui IconButton's compensation for the button border. */
margin: -2px;
/* The sidebar drawer icon, mirrored so the drawer edge points at the
preview panel on the right. */
transform: rotate(180deg);
}
53 changes: 45 additions & 8 deletions apps/ui/src/components/site-list/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { fireEvent, render, screen, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useConnector } from '@/data/core';
import { useSiteAgentActivity } from '@/data/queries/use-agent-run';
import { useAgenticFeatures } from '@/data/queries/use-agentic-features';
import { useSessions } from '@/data/queries/use-sessions';
import {
useCopySite,
Expand Down Expand Up @@ -97,6 +98,11 @@ describe( 'SiteList', () => {
paramsMock = {};
pathnameMock = '/';

vi.mocked( useAgenticFeatures ).mockReturnValue( {
enabled: true,
reason: null,
isReady: true,
} );
useIsSiteStartingMock.mockReturnValue( false );
useIsSiteStoppingMock.mockReturnValue( false );
useSiteAgentActivityMock.mockReturnValue( 'idle' );
Expand Down Expand Up @@ -178,10 +184,10 @@ describe( 'SiteList', () => {
expect( actionGlyph?.querySelector( 'span' ) ).toBeInTheDocument();
} );

it( 'opens site actions from the row without opening the latest chat', async () => {
it( 'opens site actions from a row right-click without opening the latest chat', async () => {
render( <SiteList /> );

fireEvent.click( screen.getAllByRole( 'button', { name: 'Site actions' } )[ 0 ] );
fireEvent.contextMenu( screen.getByText( 'Stopped Site' ) );

expect( navigateMock ).not.toHaveBeenCalled();
expect( await screen.findByText( 'Site settings' ) ).toBeInTheDocument();
Expand All @@ -194,12 +200,43 @@ describe( 'SiteList', () => {
it( 'opens site settings from the site actions menu', async () => {
render( <SiteList /> );

fireEvent.click( screen.getAllByRole( 'button', { name: 'Site actions' } )[ 0 ] );
fireEvent.contextMenu( screen.getByText( 'Stopped Site' ) );
fireEvent.click( await screen.findByText( 'Site settings' ) );

expect( navigateMock ).toHaveBeenCalledTimes( 1 );
expect( navigateMock ).toHaveBeenLastCalledWith( {
to: '/sites/$siteId/settings',
to: '/sites/$siteId/overview',
params: { siteId: 'stopped-site' },
search: { tab: 'general' },
} );
} );

it( 'opens the site overview when clicking a site while agentic features are unavailable', () => {
vi.mocked( useAgenticFeatures ).mockReturnValue( {
enabled: false,
reason: 'signed-out',
isReady: true,
} );

render( <SiteList /> );

fireEvent.click( screen.getByText( 'Stopped Site' ) );

expect( navigateMock ).toHaveBeenCalledTimes( 1 );
expect( navigateMock ).toHaveBeenLastCalledWith( {
to: '/sites/$siteId/overview',
params: { siteId: 'stopped-site' },
} );
} );

it( 'opens the site overview from the row gear without opening the latest chat', () => {
render( <SiteList /> );

fireEvent.click( screen.getAllByRole( 'button', { name: 'Site overview' } )[ 0 ] );

expect( navigateMock ).toHaveBeenCalledTimes( 1 );
expect( navigateMock ).toHaveBeenLastCalledWith( {
to: '/sites/$siteId/overview',
params: { siteId: 'stopped-site' },
} );
} );
Expand Down Expand Up @@ -235,9 +272,9 @@ describe( 'SiteList', () => {
expect( siteButton ).toHaveAttribute( 'aria-current', 'page' );
} );

it( 'marks the site row as contextual on site settings routes', () => {
it( 'marks the site row as contextual on the site overview route', () => {
paramsMock = { siteId: 'stopped-site' };
pathnameMock = '/sites/stopped-site/settings';
pathnameMock = '/sites/stopped-site/overview';

render( <SiteList /> );

Expand Down Expand Up @@ -638,7 +675,7 @@ describe( 'SiteList', () => {

render( <SiteList /> );

fireEvent.click( screen.getAllByRole( 'button', { name: 'Site actions' } )[ 0 ] );
fireEvent.contextMenu( screen.getByText( 'Stopped Site' ) );

const editorItem = await screen.findByText( 'Open in Zed' );
expect( screen.getByText( 'Open in Terminal' ) ).toBeInTheDocument();
Expand All @@ -654,7 +691,7 @@ describe( 'SiteList', () => {

render( <SiteList /> );

fireEvent.click( screen.getAllByRole( 'button', { name: 'Site actions' } )[ 0 ] );
fireEvent.contextMenu( screen.getByText( 'Stopped Site' ) );

await screen.findByText( 'Open folder' );
expect( screen.queryByText( /Open in / ) ).not.toBeInTheDocument();
Expand Down
Loading
Loading