From d74b8558045196e5ce08f0173747cf8dc1e6a92f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 29 Sep 2026 11:35:52 -0700 Subject: [PATCH 1/2] improvement(desktop): surface update actions in a persistent toast --- .github/workflows/desktop-e2e.yml | 3 + .../e2e/fixtures/update-notification.tsx | 35 +++ apps/desktop/e2e/update-notification.spec.ts | 242 ++++++++++++++++++ .../_shell/desktop-update-notification.tsx | 54 ++++ apps/sim/app/layout.tsx | 2 + 5 files changed, 336 insertions(+) create mode 100644 apps/desktop/e2e/fixtures/update-notification.tsx create mode 100644 apps/desktop/e2e/update-notification.spec.ts create mode 100644 apps/sim/app/_shell/desktop-update-notification.tsx diff --git a/.github/workflows/desktop-e2e.yml b/.github/workflows/desktop-e2e.yml index 0e8d3c8d697..df8bfd5ef5b 100644 --- a/.github/workflows/desktop-e2e.yml +++ b/.github/workflows/desktop-e2e.yml @@ -10,6 +10,9 @@ on: - '.github/workflows/desktop-e2e.yml' - '.github/workflows/desktop-release.yml' - 'apps/desktop/**' + - 'apps/sim/app/_shell/desktop-update-*.tsx' + - 'apps/sim/hooks/use-desktop-update-state.ts' + - 'apps/sim/lib/desktop/**' - 'apps/sim/app/workspace/**/browser-session/**' - 'apps/sim/app/_styles/**' - 'apps/sim/lib/postcss/**' diff --git a/apps/desktop/e2e/fixtures/update-notification.tsx b/apps/desktop/e2e/fixtures/update-notification.tsx new file mode 100644 index 00000000000..fa0308f5380 --- /dev/null +++ b/apps/desktop/e2e/fixtures/update-notification.tsx @@ -0,0 +1,35 @@ +import { type ComponentType, StrictMode, useState } from 'react' +import { Chip, ChipInput, ToastProvider } from '@sim/emcn' +import { PathnameContext } from 'next/dist/shared/lib/hooks-client-context.shared-runtime' +import { createRoot } from 'react-dom/client' + +interface UpdateNotificationFixtureProps { + Notification: ComponentType +} + +function UpdateNotificationFixture({ Notification }: UpdateNotificationFixtureProps) { + const [pathname, setPathname] = useState('/workspace/first') + return ( + + + +
+ + setPathname('/workspace/second')}>Switch workspace + {pathname} +
+
+
+ ) +} + +/** Supplies the production notification through Sim's module aliases. */ +export function mountUpdateNotificationFixture(Notification: ComponentType) { + const root = document.getElementById('root') + if (!root) throw new Error('Missing fixture root') + createRoot(root).render( + + + + ) +} diff --git a/apps/desktop/e2e/update-notification.spec.ts b/apps/desktop/e2e/update-notification.spec.ts new file mode 100644 index 00000000000..8161316228d --- /dev/null +++ b/apps/desktop/e2e/update-notification.spec.ts @@ -0,0 +1,242 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createServer } from 'node:http' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { _electron as electron, expect, test } from '@playwright/test' +import type { DesktopUpdateState } from '@sim/desktop-bridge' +import { getErrorMessage } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' +import { build } from 'esbuild' +import postcss from 'postcss' +import loadPostcssConfig from 'postcss-load-config' + +const DESKTOP_DIR = fileURLToPath(new URL('..', import.meta.url)) +const SIM_DIR = fileURLToPath(new URL('../../sim/', import.meta.url)) +const FIXTURE = fileURLToPath(new URL('./fixtures/update-notification.tsx', import.meta.url)) + +test('desktop update actions survive navigation without repeating dismissed or stale offers', async () => { + const reportPath = + process.env.DESKTOP_UPDATE_NOTICE_REPORT_PATH ?? test.info().outputPath('update-notice.json') + const checks: { + name: string + status: 'passed' | 'failed' + durationMs: number + error?: string + }[] = [] + const check = async (name: string, run: () => Promise) => { + const started = Date.now() + try { + await test.step(name, run) + checks.push({ name, status: 'passed', durationMs: Date.now() - started }) + } catch (error) { + checks.push({ + name, + status: 'failed', + durationMs: Date.now() - started, + error: getErrorMessage(error), + }) + throw error + } + } + const userData = mkdtempSync(join(tmpdir(), 'sim-update-notice-e2e-')) + let app: Awaited> | undefined + let passed = false + let javascript = '' + let stylesheet = '' + const server = createServer((request, response) => { + const path = new URL(request.url ?? '/', 'http://localhost').pathname + if (path === '/fixture.js' || path === '/fixture.css') { + response.setHeader('Content-Type', path.endsWith('.js') ? 'text/javascript' : 'text/css') + response.end(path.endsWith('.js') ? javascript : stylesheet) + } else if (path.startsWith('/api/')) { + response.setHeader('Content-Type', 'application/json') + response.end('{}') + } else { + response.setHeader('Content-Type', 'text/html') + response.end( + '
' + ) + } + }) + + try { + await check('load the production notification in Electron', async () => { + const config = await loadPostcssConfig({}, SIM_DIR) + const cssPath = join(SIM_DIR, 'app/_styles/globals.css') + const css = await postcss(config.plugins).process( + `${readFileSync(cssPath, 'utf8')}\n@source ${JSON.stringify(FIXTURE)};`, + { from: cssPath } + ) + const bundle = await build({ + stdin: { + contents: `import { mountUpdateNotificationFixture } from ${JSON.stringify(FIXTURE)}; +import { DesktopUpdateNotification } from '@/app/_shell/desktop-update-notification'; +mountUpdateNotificationFixture(DesktopUpdateNotification);`, + resolveDir: SIM_DIR, + loader: 'tsx', + }, + bundle: true, + write: false, + outfile: test.info().outputPath('fixture.js'), + external: ['node:async_hooks'], + banner: { js: 'var process={env:{NODE_ENV:"development"},browser:true};' }, + format: 'iife', + platform: 'browser', + tsconfig: join(SIM_DIR, 'tsconfig.json'), + define: { 'process.env.NODE_ENV': '"development"' }, + }) + javascript = bundle.outputFiles.find((file) => file.path.endsWith('.js'))?.text ?? '' + stylesheet = `${css.css}\n${bundle.outputFiles.find((file) => file.path.endsWith('.css'))?.text ?? ''}` + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (!address || typeof address === 'string') throw new Error('Missing fixture address') + app = await electron.launch({ + args: [process.env.SIM_DESKTOP_E2E_MAIN ?? '.'], + cwd: DESKTOP_DIR, + env: { + ...process.env, + SIM_DESKTOP_ORIGIN: `http://127.0.0.1:${address.port}`, + SIM_DESKTOP_USER_DATA: userData, + }, + }) + }) + if (!app) throw new Error('Electron did not launch') + const shell = app + const page = await shell.firstWindow() + const errors: string[] = [] + page.on('pageerror', (error) => errors.push(error.message)) + await shell.evaluate(({ app, BrowserWindow }) => { + const window = BrowserWindow.getAllWindows()[0] + window.setContentSize(1000, 700) + window.webContents.setBackgroundThrottling(false) + app.focus({ steal: true }) + window.focus() + }) + const publish = (state: DesktopUpdateState) => + shell.evaluate(({ BrowserWindow }, next) => { + BrowserWindow.getAllWindows()[0].webContents.send('desktop:updates:state', next) + }, state) + const notice = page.getByRole('list', { name: 'Notifications' }) + const restart = notice.getByRole('button', { name: 'Restart to update' }) + const download = notice.getByRole('button', { name: 'Download update' }) + + await check( + 'initial snapshot survives StrictMode and remains actionable across navigation', + async () => { + await shell.evaluate(({ ipcMain }) => { + ipcMain.removeHandler('desktop:updates:get-state') + ipcMain.handle('desktop:updates:get-state', () => ({ status: 'ready', version: '2.0.0' })) + }) + await page.reload() + await expect(restart).toHaveCount(1) + await expect(restart).toBeVisible() + await page.getByRole('button', { name: 'Switch workspace' }).click() + await expect(page.getByLabel('Current route')).toHaveText('/workspace/second') + await sleep(5_500) + await expect(restart).toBeVisible() + await page.screenshot({ + path: test.info().outputPath('update-ready-dark.png'), + animations: 'disabled', + }) + await page.evaluate(() => document.documentElement.classList.remove('dark')) + await page.screenshot({ + path: test.info().outputPath('update-ready-light.png'), + animations: 'disabled', + }) + } + ) + + await check('dismissal survives repeated events and checking the same release', async () => { + await notice.getByRole('button', { name: 'Dismiss notification' }).click() + await expect(restart).toHaveCount(0) + await publish({ status: 'ready', version: '2.0.0' }) + await publish({ status: 'checking', version: '2.0.0' }) + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + ) + ) + await publish({ status: 'ready', version: '2.0.0' }) + await sleep(500) + await expect(restart).toHaveCount(0) + }) + + await check( + 'a new release appears without stealing focus, and stale actions are withdrawn', + async () => { + const input = page.getByRole('textbox', { name: 'Work in progress' }) + await input.fill('Unsaved work') + await publish({ status: 'ready', version: '2.1.0' }) + await expect(restart).toBeVisible() + await expect(input).toBeFocused() + for (const status of ['checking', 'downloading', 'error', 'idle'] as const) { + await publish({ status, version: '2.1.0' }) + await expect(restart).toHaveCount(0) + await publish({ status: 'ready', version: '2.1.0' }) + await expect(restart).toHaveCount(1) + } + await expect(input).toHaveValue('Unsaved work') + } + ) + + await check( + 'a dismissed download offer can announce that the same release is ready', + async () => { + await publish({ status: 'available', version: '2.1.0' }) + await expect(download).toBeVisible() + await expect(restart).toHaveCount(0) + await notice.getByRole('button', { name: 'Dismiss notification' }).click() + await expect(download).toHaveCount(0) + await publish({ status: 'ready', version: '2.1.0' }) + await expect(restart).toBeVisible() + } + ) + + await check('actions reach the real preload IPC with the correct operation', async () => { + for (const { state, action, channel } of [ + { + state: { status: 'ready', version: '2.1.0' }, + action: restart, + channel: 'desktop:updates:install', + }, + { + state: { status: 'available', version: '2.2.0' }, + action: download, + channel: 'desktop:updates:check', + }, + { + state: { status: 'available', version: '2.3.0', manual: true }, + action: download, + channel: 'desktop:updates:install', + }, + ] satisfies { state: DesktopUpdateState; action: typeof restart; channel: string }[]) { + await publish(state) + await expect(action).toBeVisible() + const received = await shell.evaluateHandle(({ ipcMain }, name) => { + const receipt: { url: string | null } = { url: null } + ipcMain.once(name, (event) => { + receipt.url = event.sender.getURL() + }) + return receipt + }, channel) + try { + await action.click() + await expect.poll(() => received.evaluate((receipt) => receipt.url)).toBe(page.url()) + await expect(action).toHaveCount(0) + } finally { + await received.dispose() + } + } + expect(errors).toEqual([]) + }) + passed = true + } finally { + mkdirSync(dirname(reportPath), { recursive: true }) + writeFileSync(reportPath, JSON.stringify({ passed, checks }, null, 2)) + await app?.close() + await new Promise((resolve) => server.close(() => resolve())) + rmSync(userData, { recursive: true, force: true }) + } +}) diff --git a/apps/sim/app/_shell/desktop-update-notification.tsx b/apps/sim/app/_shell/desktop-update-notification.tsx new file mode 100644 index 00000000000..9e3347fb9d6 --- /dev/null +++ b/apps/sim/app/_shell/desktop-update-notification.tsx @@ -0,0 +1,54 @@ +'use client' + +import { useEffect, useRef } from 'react' +import { useToast } from '@sim/emcn' +import { getDesktopShellVersion, getDesktopUpdates } from '@/lib/desktop' +import { isShellOutdated } from '@/lib/desktop/min-version' +import { useDesktopUpdateState } from '@/hooks/use-desktop-update-state' + +/** Keeps optional desktop updates actionable across routes for the current window session. */ +export function DesktopUpdateNotification() { + const { status, version, manual } = useDesktopUpdateState() + const { toast, dismiss } = useToast() + const dismissedOffer = useRef(null) + + useEffect(() => { + const updates = getDesktopUpdates() + if ( + !updates || + isShellOutdated(getDesktopShellVersion()) || + (status !== 'available' && status !== 'ready') + ) { + return + } + + const offer = `${version ?? ''}:${status}:${Boolean(manual)}` + if (dismissedOffer.current === offer) return + + let active = true + const id = toast({ + message: + status === 'ready' ? 'A Sim update is ready to install' : 'A Sim update is available', + action: { + label: status === 'ready' ? 'Restart to update' : 'Download update', + onClick: () => { + if (!active) return + if (status === 'ready' || manual) updates.install() + else updates.check() + }, + }, + persistAcrossRoutes: true, + onDismiss: () => { + if (active) dismissedOffer.current = offer + }, + }) + + return () => { + // State changes and StrictMode cleanup withdraw the toast without dismissing the offer. + active = false + dismiss(id) + } + }, [status, version, manual, toast, dismiss]) + + return null +} diff --git a/apps/sim/app/layout.tsx b/apps/sim/app/layout.tsx index 7fc27f4970f..d542ca8eb17 100644 --- a/apps/sim/app/layout.tsx +++ b/apps/sim/app/layout.tsx @@ -17,6 +17,7 @@ import { } from '@/lib/core/config/env-flags' import { ConsentProvider } from '@/app/_shell/consent/consent-provider' import { DesktopUpdateGate } from '@/app/_shell/desktop-update-gate' +import { DesktopUpdateNotification } from '@/app/_shell/desktop-update-notification' import { HydrationErrorHandler } from '@/app/_shell/hydration-error-handler' import { QueryProvider } from '@/app/_shell/providers/query-provider' import { SessionProvider } from '@/app/_shell/providers/session-provider' @@ -44,6 +45,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) const themeCSS = generateThemeCSS() const application = ( + From fc4aede93eba5a059697c7f8ba8272728f21b706 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 29 Sep 2026 11:50:41 -0700 Subject: [PATCH 2/2] fix(desktop): distinguish update toast dismissal from eviction --- .github/workflows/desktop-e2e.yml | 1 + .../e2e/fixtures/update-notification.tsx | 12 ++++++++- apps/desktop/e2e/password-autofill.spec.ts | 3 +++ apps/desktop/e2e/update-notification.spec.ts | 26 ++++++++++++++++--- .../_shell/desktop-update-notification.tsx | 10 ++++--- packages/emcn/src/components/toast/toast.tsx | 9 ++++++- 6 files changed, 53 insertions(+), 8 deletions(-) diff --git a/.github/workflows/desktop-e2e.yml b/.github/workflows/desktop-e2e.yml index df8bfd5ef5b..d8c2ddafde0 100644 --- a/.github/workflows/desktop-e2e.yml +++ b/.github/workflows/desktop-e2e.yml @@ -11,6 +11,7 @@ on: - '.github/workflows/desktop-release.yml' - 'apps/desktop/**' - 'apps/sim/app/_shell/desktop-update-*.tsx' + - 'apps/sim/app/layout.tsx' - 'apps/sim/hooks/use-desktop-update-state.ts' - 'apps/sim/lib/desktop/**' - 'apps/sim/app/workspace/**/browser-session/**' diff --git a/apps/desktop/e2e/fixtures/update-notification.tsx b/apps/desktop/e2e/fixtures/update-notification.tsx index fa0308f5380..cf707a0a325 100644 --- a/apps/desktop/e2e/fixtures/update-notification.tsx +++ b/apps/desktop/e2e/fixtures/update-notification.tsx @@ -1,5 +1,5 @@ import { type ComponentType, StrictMode, useState } from 'react' -import { Chip, ChipInput, ToastProvider } from '@sim/emcn' +import { Chip, ChipInput, ToastProvider, toast } from '@sim/emcn' import { PathnameContext } from 'next/dist/shared/lib/hooks-client-context.shared-runtime' import { createRoot } from 'react-dom/client' @@ -16,6 +16,16 @@ function UpdateNotificationFixture({ Notification }: UpdateNotificationFixturePr
setPathname('/workspace/second')}>Switch workspace + { + for (let index = 0; index < 3; index++) { + toast({ message: `Background task ${index + 1}`, duration: 0 }) + } + }} + > + Fill notification stack + + toast.dismissAll()}>Clear notifications {pathname}
diff --git a/apps/desktop/e2e/password-autofill.spec.ts b/apps/desktop/e2e/password-autofill.spec.ts index 949581bce3c..8a00ba9bbd3 100644 --- a/apps/desktop/e2e/password-autofill.spec.ts +++ b/apps/desktop/e2e/password-autofill.spec.ts @@ -311,6 +311,9 @@ test.describe('saved password autofill', () => { await expect .poll(() => pageScript('document.getElementById("user").value')) .toBe('account9@example.test') + await expect + .poll(() => app.windows().some((page) => page.url().includes('credential-picker.html'))) + .toBe(false) await clickField() await picker() await pickerKey('Escape') diff --git a/apps/desktop/e2e/update-notification.spec.ts b/apps/desktop/e2e/update-notification.spec.ts index 8161316228d..b088ef4ca78 100644 --- a/apps/desktop/e2e/update-notification.spec.ts +++ b/apps/desktop/e2e/update-notification.spec.ts @@ -194,20 +194,40 @@ mountUpdateNotificationFixture(DesktopUpdateNotification);`, } ) + await check('stack eviction does not count as dismissing the update offer', async () => { + await publish({ status: 'checking', version: '2.4.0' }) + await expect(restart).toHaveCount(0) + await publish({ status: 'ready', version: '2.4.0' }) + await expect(restart).toHaveCount(1) + await page.getByRole('button', { name: 'Fill notification stack' }).click() + await expect(restart).toHaveCount(0) + await page.getByRole('button', { name: 'Clear notifications' }).click() + await expect(notice).toHaveCount(0) + await publish({ status: 'checking', version: '2.4.0' }) + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + ) + ) + await publish({ status: 'ready', version: '2.4.0' }) + await expect(restart).toBeVisible() + }) + await check('actions reach the real preload IPC with the correct operation', async () => { for (const { state, action, channel } of [ { - state: { status: 'ready', version: '2.1.0' }, + state: { status: 'ready', version: '2.4.0' }, action: restart, channel: 'desktop:updates:install', }, { - state: { status: 'available', version: '2.2.0' }, + state: { status: 'available', version: '2.5.0' }, action: download, channel: 'desktop:updates:check', }, { - state: { status: 'available', version: '2.3.0', manual: true }, + state: { status: 'available', version: '2.6.0', manual: true }, action: download, channel: 'desktop:updates:install', }, diff --git a/apps/sim/app/_shell/desktop-update-notification.tsx b/apps/sim/app/_shell/desktop-update-notification.tsx index 9e3347fb9d6..1ae21a07242 100644 --- a/apps/sim/app/_shell/desktop-update-notification.tsx +++ b/apps/sim/app/_shell/desktop-update-notification.tsx @@ -6,8 +6,12 @@ import { getDesktopShellVersion, getDesktopUpdates } from '@/lib/desktop' import { isShellOutdated } from '@/lib/desktop/min-version' import { useDesktopUpdateState } from '@/hooks/use-desktop-update-state' +interface DesktopUpdateNotificationProps { + [key: string]: never +} + /** Keeps optional desktop updates actionable across routes for the current window session. */ -export function DesktopUpdateNotification() { +export function DesktopUpdateNotification(_props: DesktopUpdateNotificationProps) { const { status, version, manual } = useDesktopUpdateState() const { toast, dismiss } = useToast() const dismissedOffer = useRef(null) @@ -38,13 +42,13 @@ export function DesktopUpdateNotification() { }, }, persistAcrossRoutes: true, - onDismiss: () => { + onUserDismiss: () => { if (active) dismissedOffer.current = offer }, }) return () => { - // State changes and StrictMode cleanup withdraw the toast without dismissing the offer. + // Ignore clicks while a withdrawn toast finishes its exit animation. active = false dismiss(id) } diff --git a/packages/emcn/src/components/toast/toast.tsx b/packages/emcn/src/components/toast/toast.tsx index 4c4e31c05a4..6712029cac7 100644 --- a/packages/emcn/src/components/toast/toast.tsx +++ b/packages/emcn/src/components/toast/toast.tsx @@ -100,6 +100,7 @@ interface ToastData { duration: number persistAcrossRoutes: boolean onDismiss?: () => void + onUserDismiss?: () => void } interface ToastRemoval { @@ -174,6 +175,8 @@ type ToastInput = { action?: ToastAction /** Called once when the toast leaves the stack, regardless of how it was dismissed. */ onDismiss?: () => void + /** Called directly when the user closes the toast or selects its action, before removal. */ + onUserDismiss?: () => void duration?: number /** * Keep the toast across navigation. The stack is otherwise cleared on every @@ -374,7 +377,10 @@ function ToastItem({ toast: t, geometry, reduceMotion, onDismiss, onMeasure }: T return () => observer.disconnect() }, [t.id, onMeasure]) - const dismiss = useCallback(() => onDismiss(t.id), [onDismiss, t.id]) + const dismiss = () => { + t.onUserDismiss?.() + onDismiss(t.id) + } const { y, scale, height, zIndex } = geometry const cornerRadius = height <= COMPACT_CARD_HEIGHT_PX ? COMPACT_RADIUS_PX : CONCENTRIC_RADIUS_PX @@ -539,6 +545,7 @@ export function ToastProvider({ children }: { children?: ReactNode }) { duration: input.duration ?? (input.action ? 0 : AUTO_DISMISS_MS), persistAcrossRoutes: input.persistAcrossRoutes ?? false, onDismiss: input.onDismiss, + onUserDismiss: input.onUserDismiss, } dispatch({ type: 'add', toast: data }) return id