Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/studio/src/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1522,6 +1522,8 @@ export async function isAgenticUiBannerDismissed( _event: IpcMainInvokeEvent ):
return userData.agenticUiBannerDismissed === true;
}

export { getAppUpdateStatus, installAppUpdate } from 'src/updates';

export async function executeWPCLiInline(
_event: IpcMainInvokeEvent,
{
Expand Down
6 changes: 6 additions & 0 deletions apps/studio/src/ipc-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ export interface IpcEvents {
'ai-agent-event': [ AgentRunEvent ];
'ai-session-placement-updated': [ AiSessionPlacementUpdatedEvent ];
'remote-session-status': [ RemoteSessionStatus ];
'app-update-status': [ AppUpdateStatus ];
}

export interface AppUpdateStatus {
readyToInstall: boolean;
version: string | null;
}

let isAppQuitting = false;
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 @@ -91,6 +91,8 @@ const api: IpcApi = {
disableAgenticUi: () => ipcRendererInvoke( 'disableAgenticUi' ),
dismissAgenticUiBanner: () => ipcRendererInvoke( 'dismissAgenticUiBanner' ),
isAgenticUiBannerDismissed: () => ipcRendererInvoke( 'isAgenticUiBannerDismissed' ),
getAppUpdateStatus: () => ipcRendererInvoke( 'getAppUpdateStatus' ),
installAppUpdate: () => ipcRendererInvoke( 'installAppUpdate' ),
getWpVersion: ( id ) => ipcRendererInvoke( 'getWpVersion', id ),
getIsMultisite: ( id ) => ipcRendererInvoke( 'getIsMultisite', id ),
generateProposedSitePath: ( siteName ) =>
Expand Down
26 changes: 24 additions & 2 deletions apps/studio/src/updates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import { app, autoUpdater, clipboard, dialog } from 'electron';
import * as Sentry from '@sentry/electron/main';
import { sprintf, __ } from '@wordpress/i18n';
import { AUTO_UPDATE_INTERVAL_MS, NIGHTLY_UPDATE_TTL_MS } from 'src/constants';
import { sendIpcEventToRenderer, type AppUpdateStatus } from 'src/ipc-utils';
import { shellOpenExternalWrapper } from 'src/lib/shell-open-external-wrapper';
import { isDevRelease } from 'src/lib/version-utils';
import { getMainWindow } from 'src/main-window';
import { loadUserData, updateAppdata } from 'src/storage/user-data';
import type { IpcMainInvokeEvent } from 'electron';

type UpdpaterState =
| 'init'
Expand All @@ -16,6 +18,7 @@ type UpdpaterState =
| 'waiting-for-restart'; // download is complete, app will update after restart

let updaterState: UpdpaterState = 'init';
let downloadedVersion: string | null = null;

let timeout: NodeJS.Timeout | null = null;

Expand Down Expand Up @@ -132,9 +135,11 @@ export function setupUpdates() {
console.log( 'Update available' );
} );

autoUpdater.on( 'update-downloaded', async () => {
autoUpdater.on( 'update-downloaded', async ( _event, releaseNotes, releaseName ) => {
updaterState = 'waiting-for-restart';
console.log( 'Update has been downloaded' );
downloadedVersion = typeof releaseName === 'string' ? releaseName : null;
console.log( 'Update has been downloaded', { version: downloadedVersion } );
void sendIpcEventToRenderer( 'app-update-status', buildAppUpdateStatus() );
await showUpdateReadyToInstallNotice();
} );

Expand Down Expand Up @@ -443,3 +448,20 @@ async function showReadOnlyVolumeError( err: Error ) {
detail: `${ detailMessage }\n\n${ detailPath }`,
} );
}

function buildAppUpdateStatus(): AppUpdateStatus {
return {
readyToInstall: updaterState === 'waiting-for-restart',
version: downloadedVersion,
};
}

export async function getAppUpdateStatus( _event: IpcMainInvokeEvent ): Promise< AppUpdateStatus > {
return buildAppUpdateStatus();
}

export async function installAppUpdate( _event: IpcMainInvokeEvent ): Promise< void > {
if ( updaterState === 'waiting-for-restart' ) {
autoUpdater.quitAndInstall();
}
}
2 changes: 2 additions & 0 deletions apps/ui/src/app/app-providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Tooltip } from '@wordpress/ui';
import { useEffect } from 'react';
import { ConnectorProvider, queryClient } from '@/data/core';
import { AgentRunProvider } from '@/data/queries/use-agent-run';
import { useSyncAppUpdateStatus } from '@/data/queries/use-app-update';
import { useSyncSessionsWithEvents } from '@/data/queries/use-sessions';
import { useAutoStartSites, useSyncSitesWithEvents } from '@/data/queries/use-sites';
import { useColorScheme } from '@/hooks/use-color-scheme';
Expand All @@ -25,6 +26,7 @@ function SiteEventsBridge() {
useSyncSessionsWithEvents();
useSyncConnectSiteListener();
useAutoStartSites();
useSyncAppUpdateStatus();
return null;
}

Expand Down
57 changes: 57 additions & 0 deletions apps/ui/src/components/app-message-cards/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { __ } from '@wordpress/i18n';
import { Button, Notice } from '@wordpress/ui';
import { clsx } from 'clsx';
import toastStyles from '@/components/app-toasts/style.module.css';
import { useActivePersistentMessages } from '@/data/queries/use-app-messages';
import styles from './style.module.css';

export function AppMessageCards( { className }: { className?: string } ) {
const { messages, dismiss } = useActivePersistentMessages();

if ( ! messages.length ) {
return null;
}

return (
<div className={ clsx( styles.stack, className ) }>
{ messages.map( ( message ) => (
<div key={ message.id } className={ styles.cell }>
<Notice.Root
intent={ message.intent }
icon={ null }
className={ clsx( toastStyles.notice, styles.card ) }
>
<Notice.Title>{ message.title }</Notice.Title>
{ message.description ? (
<Notice.Description>{ message.description }</Notice.Description>
) : null }
{ message.cta ? (
<Notice.Actions>
<Button
size="small"
variant="solid"
tone="neutral"
className={ toastStyles.actionButton }
onClick={ message.cta.onClick }
>
{ message.cta.label }
</Button>
</Notice.Actions>
) : null }
<Notice.CloseIcon label={ __( 'Dismiss' ) } onClick={ () => dismiss( message ) } />
</Notice.Root>
</div>
) ) }
</div>
);
}

export function AppMessageCardsDot( { className }: { className?: string } ) {
const { messages } = useActivePersistentMessages();

if ( ! messages.length ) {
return null;
}

return <span className={ clsx( styles.dot, className ) } aria-hidden="true" />;
}
78 changes: 78 additions & 0 deletions apps/ui/src/components/app-message-cards/style.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/* Cells own their spacing (no gap); the container's negative margin cancels
the first cell's share. Mirrors the toast stack (see
app-toasts/style.module.css). */
.stack {
display: flex;
flex-direction: column;
margin-top: calc(-1 * var(--wpds-dimension-gap-sm));
}

/* Collapsible row, same treatment as the toasts: @starting-style animates
0fr→1fr on mount so an arriving card pushes its neighbors instead of
making them jump. */
.cell {
display: grid;
grid-template-rows: 1fr;
margin-top: var(--wpds-dimension-gap-sm);
transition:
grid-template-rows 180ms ease,
margin-top 180ms ease;
}

@starting-style {
.cell {
grid-template-rows: 0fr;
margin-top: 0;
}
}

/* The grid item must be shrinkable for 0fr to collapse it; `clip` with a
margin keeps ring/elevation shadows visible in steady state. */
.cell > * {
min-height: 0;
overflow: clip;
overflow-clip-margin: 12px;
}

/* Layered on top of the toasts' `notice` class (applied together in the
component); a touch more block padding since cards carry a title +
description + action. */
.card {
padding-block: var(--wpds-dimension-padding-sm);
/* Same entrance the ephemeral toasts use (toast-enter in app-toasts) so
cards and toasts read as one system. */
animation: card-enter 160ms ease-out;
}

@keyframes card-enter {
from {
opacity: 0;
transform: translateY(6px);
}

to {
opacity: 1;
transform: translateY(0);
}
}

@media (prefers-reduced-motion: reduce) {
.card,
.cell {
animation: none;
transition: none;
}
}

/* Sits inside the floating sidebar toggle's button wrapper, pinned to the
icon button's top-right corner. */
.dot {
position: absolute;
top: -1px;
right: -1px;
width: 6px;
height: 6px;
border-radius: 50%;
background-color: var(--wpds-color-fg-interactive-brand);
pointer-events: none;
}
5 changes: 5 additions & 0 deletions apps/ui/src/components/sidebar-layout/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ vi.mock( '@/components/sidebar-header', () => ( {
SidebarHeader: () => null,
} ) );

vi.mock( '@/components/app-message-cards', () => ( {
AppMessageCards: () => null,
AppMessageCardsDot: () => null,
} ) );

vi.mock( '@/components/site-list', () => ( {
SiteList: () => <nav aria-label="Sites" />,
} ) );
Expand Down
24 changes: 16 additions & 8 deletions apps/ui/src/components/sidebar-layout/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { privateApis } from '@wordpress/theme';
import { IconButton } from '@wordpress/ui';
import { clsx } from 'clsx';
import { useCallback, useEffect, useState } from 'react';
import { AppMessageCards, AppMessageCardsDot } from '@/components/app-message-cards';
import { AppToasts } from '@/components/app-toasts';
import { ResizeHandle, ResizeOverlay } from '@/components/resize-handle';
import { SidebarHeader } from '@/components/sidebar-header';
Expand Down Expand Up @@ -67,7 +68,11 @@ export function SidebarLayout( { children }: { children: ReactNode } ) {
<SidebarHeader />
<SiteList />
<div className={ styles.sidebarFooter }>
{ /* Toasts sit above the persistent cards: the footer is
bottom-anchored, so a transient toast arriving below a card
would shove it up and drop it back on expiry. */ }
{ ! collapsed ? <AppToasts className={ styles.sidebarToasts } /> : null }
{ ! collapsed ? <AppMessageCards className={ styles.sidebarCards } /> : null }
<UserMenu onToggleSidebar={ toggleSidebar } />
</div>
</div>
Expand Down Expand Up @@ -97,14 +102,17 @@ export function SidebarLayout( { children }: { children: ReactNode } ) {
! reserveTrafficLightSpace && styles.floatingToggleFlush
) }
>
<IconButton
variant="minimal"
tone="neutral"
size="small"
icon={ drawerIcon }
label={ __( 'Show sidebar' ) }
onClick={ toggleSidebar }
/>
<span className={ styles.floatingToggleButton }>
<IconButton
variant="minimal"
tone="neutral"
size="small"
icon={ drawerIcon }
label={ __( 'Show sidebar' ) }
onClick={ toggleSidebar }
/>
<AppMessageCardsDot />
</span>
</div>
) : null }
{ children }
Expand Down
9 changes: 9 additions & 0 deletions apps/ui/src/components/sidebar-layout/style.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@
justify-content: flex-start;
}

.sidebarCards {
padding: var(--wpds-dimension-padding-sm) calc(var(--wpds-dimension-padding-lg) - var(--wpds-dimension-padding-sm)) 0;
}

/* Sidebar-specific indicator treatment: match the content card's vertical
extent instead of running through the frame gaps above/below it (keep the
12px in sync with --panel-frame-gap in preview-split-frame). The handle is
Expand Down Expand Up @@ -117,6 +121,11 @@
left: var(--wpds-dimension-padding-xl);
}

.floatingToggleButton {
position: relative;
display: inline-flex;
}

.sidebarToasts {
padding: var(--wpds-dimension-padding-sm) calc(var(--wpds-dimension-padding-lg) - var(--wpds-dimension-padding-sm)) 0;
}
Expand Down
9 changes: 9 additions & 0 deletions apps/ui/src/data/core/connectors/hosted/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -426,5 +426,14 @@ export function createHostedConnector( { apiBaseUrl }: HostedConnectorOptions ):
async disableAgenticUi() {
// No-op in the browser.
},
async getAppUpdateStatus() {
return { readyToInstall: false, version: null };
},
async installAppUpdate() {
// No-op.
},
onAppUpdateStatusChanged() {
return () => {};
},
};
}
15 changes: 15 additions & 0 deletions apps/ui/src/data/core/connectors/ipc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
InstalledApps,
LocalMediaFile,
LoadedAiSession,
AppUpdateStatus,
ProposedSitePath,
QuitSitesBehavior,
SelectedSiteFolder,
Expand Down Expand Up @@ -845,5 +846,19 @@ export function createIpcConnector(): Connector {
async disableAgenticUi(): Promise< void > {
await ipcApi.disableAgenticUi();
},

async getAppUpdateStatus() {
return ipcApi.getAppUpdateStatus();
},

async installAppUpdate(): Promise< void > {
await ipcApi.installAppUpdate();
},

onAppUpdateStatusChanged( listener ) {
return ipcListener.subscribe( 'app-update-status', ( _event: unknown, status: unknown ) =>
listener( status as AppUpdateStatus )
);
},
};
}
9 changes: 9 additions & 0 deletions apps/ui/src/data/core/connectors/local/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -774,5 +774,14 @@ export function createLocalConnector( { apiBaseUrl }: LocalConnectorOptions ): C
async disableAgenticUi() {
// No-op in the browser.
},
async getAppUpdateStatus() {
return { readyToInstall: false, version: null };
},
async installAppUpdate() {
// No-op.
},
onAppUpdateStatusChanged() {
return () => {};
},
};
}
1 change: 1 addition & 0 deletions apps/ui/src/data/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export type {
AiModelId,
AiSessionSummary,
AppGlobals,
AppUpdateStatus,
AuthUser,
ColorScheme,
Connector,
Expand Down
Loading
Loading